1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Supabase Edge Functions で 401 が出るパターンと全解決策

1
Last updated at Posted at 2026-03-17

はじめに

Supabase Edge Functions を実装していると、必ずといっていいほど出くわすのが 401 UnauthorizedInvalid JWT エラーです。

「デプロイは成功したのに、呼び出すと 401 が返ってくる」
「ローカルでは動くのに、本番だけエラーになる」
「anon キーを使っているのに認証エラーになる」

こういったエラーに何時間もハマった経験を踏まえ、原因のパターンと解決策を体系的にまとめました。

Cotty Asset(家族向け資産管理アプリ)の開発で実際に踏んだ地雷をもとに書いています。

Edge Functions の認証の仕組みを理解する

まず前提として、Supabase には3種類のキー/トークンがあります。

種類 用途 RLSの影響
anon key 匿名・非ログインユーザー向け RLS が適用される
service_role key サーバーサイド専用(管理者権限) RLS を バイパス する
User JWT ログインユーザーのセッショントークン RLS が適用される(ユーザー本人として)

Edge Functions はサーバーサイドで動くため、どのキーで呼び出すか関数内でどのキーを使うか の2軸で考える必要があります。

クライアント → [Authorization ヘッダー] → Edge Function → [Supabase DB/Auth]

よくある 401 エラーのパターンと原因

パターン1:Authorization ヘッダーが送られていない

症状

{"message":"Invalid JWT","code":401}

原因

Edge Functions はデフォルトで JWT 検証を行います。Authorization: Bearer <token> ヘッダーがない、または空の場合に 401 が返ります。

解決策

クライアント側で supabase.functions.invoke() を使っている場合は、内部で自動的にヘッダーが付与されます。

// ✅ セッションがある状態で呼び出す(Authorization は自動付与)
const { data, error } = await supabase.functions.invoke('my-function', {
  body: { userId: 'xxx' },
})

supabase.functions.invoke() の内部動作

supabase-js の実装を確認したところ、Authorization ヘッダーは以下のロジックで設定されます。

Bearer ${accessToken ?? supabaseKey}
  • セッションがある → access_token を Bearer に設定
  • セッションがない → クライアント生成時のキー(通常は anon key)をフォールバックとして使用

さらに apikey ヘッダーも自動で付与されます。fetch で直接叩く場合はこの自動付与が行われないので注意が必要です。

fetch で直接叩く場合は手動でヘッダーを付けます。

// ✅ 手動でトークンを付与(fetch で直叩きする場合)
const { data: { session } } = await supabase.auth.getSession()

const response = await fetch(`${SUPABASE_URL}/functions/v1/my-function`, {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${session?.access_token}`,
    'apikey': SUPABASE_ANON_KEY, // apikey ヘッダーも忘れずに
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ userId: 'xxx' }),
})

パターン2:anon キーを使っているが JWT 扱いになっていない

症状

ログイン前の処理(通知登録など)で 401 が返る。

原因

anon キーは「ユーザー JWT ではないが、Supabase が発行した有効なトークン」です。Edge Functions に送る際は Authorization: Bearer <anon_key> として送る必要があります。

解決策

// ✅ anon キーを直接 Bearer として送る(未ログイン時)
const response = await fetch(`${SUPABASE_URL}/functions/v1/my-function`, {
  headers: {
    'Authorization': `Bearer ${SUPABASE_ANON_KEY}`,
    'Content-Type': 'application/json',
  },
})

supabase.functions.invoke() はセッションがない場合、自動で anon キーをフォールバックとして使うので、これを使うのが最も安全です。

パターン3:Edge Function 内で service_role キーを使うべき場面で anon キーを使っている

症状

Edge Function が呼び出せるのに、DB への書き込みが row-level security policy エラーで失敗する。

原因

Edge Function 内部で Supabase クライアントを初期化するとき、anon キーを使うと RLS が適用されます。他ユーザーのデータへのアクセスや、管理者権限が必要な処理には service_role キーが必要です。

解決策

// Edge Function 内(Deno)
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

// ❌ anon キーでは RLS が適用される
const supabase = createClient(
  Deno.env.get('SUPABASE_URL')!,
  Deno.env.get('SUPABASE_ANON_KEY')!,
)

// ✅ service_role キーで RLS をバイパス
const supabaseAdmin = createClient(
  Deno.env.get('SUPABASE_URL')!,
  Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
)

⚠️ service_role キーはクライアントサイドに絶対に漏らしてはいけません。Edge Function 内の環境変数にのみ設定してください。

パターン4:JWT の有効期限切れ(Token Expired)

症状

アプリをしばらく放置した後に操作すると 401 が返る。

原因

Supabase の User JWT はデフォルトで 1時間 で失効します。セッションのリフレッシュが行われていないと、期限切れの JWT が Edge Functions に送られます。

解決策

Supabase クライアントは onAuthStateChange で自動リフレッシュしますが、アプリがバックグラウンドにいる間は動作しないことがあります。

// ✅ 呼び出し前にセッションを明示的にリフレッシュ
const { data: { session }, error } = await supabase.auth.refreshSession()

if (error || !session) {
  // ログイン画面へ誘導
  return
}

const { data } = await supabase.functions.invoke('my-function', {
  body: { ... },
})

React Native + Expo の場合、AppState の変化(バックグラウンド→フォアグラウンド)をトリガーにリフレッシュするのが実践的です。

// ✅ AppState 変化でセッションリフレッシュ
import { AppState } from 'react-native'

AppState.addEventListener('change', async (nextAppState) => {
  if (nextAppState === 'active') {
    await supabase.auth.startAutoRefresh()
  } else {
    await supabase.auth.stopAutoRefresh()
  }
})

パターン5:Edge Function を --no-verify-jwt オプションなしにデプロイしているのに JWT を送っていない

症状

Webhook など、外部サービスから叩く場合に 401 が出る。

原因

外部サービス(Stripe Webhook など)は Supabase の JWT を持っていません。この場合は JWT 検証を無効化する必要があります。

解決策

# デプロイ時に JWT 検証を無効化
supabase functions deploy my-webhook --no-verify-jwt

Edge Function 内で独自の署名検証を実装して、セキュリティを担保します。

// Stripe Webhook の例
const signature = req.headers.get('stripe-signature')
const event = await stripe.webhooks.constructEventAsync(
  body,
  signature!,
  Deno.env.get('STRIPE_WEBHOOK_SECRET')!,
)

--no-verify-jwt 使用時のセキュリティチェックリスト

JWT 検証を無効にした場合、関数内で以下を自前で実装する必要があります。

  • 署名検証Authorization 共有シークレット or プロバイダ署名(x-stripe-signature など)を HMAC で検証
  • リプレイ攻撃対策:署名対象に timestamp を含め、許容窓(例: 5分)を設ける
  • 冪等管理event_id を保存して重複受信をスキップ
  • メソッド制限:POST のみ受け付ける
  • 必須ヘッダー検証:Content-Type や署名ヘッダーの存在チェック

これらを実装せずに --no-verify-jwt を使うと、誰でも関数を叩けるエンドポイントになります。

// ✅ 汎用 Webhook 検証パターン(HMAC)
const rawBody = await req.text()
const timestamp = req.headers.get('x-webhook-timestamp') ?? ''
const signature = req.headers.get('x-webhook-signature') ?? ''

// タイムスタンプ許容窓チェック(5分)
const now = Math.floor(Date.now() / 1000)
if (Math.abs(now - parseInt(timestamp)) > 300) {
  return new Response('Timestamp expired', { status: 400 })
}

// HMAC 検証
const secret = Deno.env.get('WEBHOOK_SECRET')!
const key = await crypto.subtle.importKey(
  'raw',
  new TextEncoder().encode(secret),
  { name: 'HMAC', hash: 'SHA-256' },
  false,
  ['verify'],
)
const expectedSig = await crypto.subtle.sign(
  'HMAC',
  key,
  new TextEncoder().encode(`${timestamp}.${rawBody}`),
)
const expectedHex = Array.from(new Uint8Array(expectedSig))
  .map((b) => b.toString(16).padStart(2, '0'))
  .join('')

if (expectedHex !== signature) {
  return new Response('Invalid signature', { status: 401 })
}

getSession()getUser() の使い分け

Edge Function を呼び出す前のセッション確認で、どちらを使うか迷うことがあります。

getSession() getUser()
取得元 ローカルストレージのキャッシュ Auth サーバーに問い合わせ
速度 速い(ネットワーク不要) 遅い(ネットワーク往復あり)
信頼性 低い(キャッシュが古い可能性) 高い(本人性確認済み)
用途 UX判定・トークン取得 認可判断の根拠

実務的な判断基準

  • 画面の表示/非表示の判定、トークンの取得getSession() で十分
  • 「このユーザーがこの操作をしていい」という認可の根拠に使う場合getUser() を使う(または Edge Function 側で検証する)

supabase.functions.invoke() 自体は内部で自動的に Authorization を付けるため、呼び出し前に getSession()getUser() を毎回実行する必要はありません。ただしセッション切れの検出やエラーハンドリングのために確認したい場合は getSession() が軽量でおすすめです。

401 と 403 の違いを正確に理解する

Supabase でエラーが返ったとき、401403別の意味を持ちます。

Data API(PostgREST)の場合

状況 HTTPステータス
未認証 + データアクセス(RLS で denied) 401
認証済み + RLS でアクセス拒否 403

PostgreSQL のエラーコード 42501 insufficient_privilege は、認証状態に応じて 401/403 に振り分けられます。

Edge Functions の場合

状況 HTTPステータス
JWT が不正・欠落(verify_jwt=true 時) 401
関数ロジックでアクセス拒否を返す場合 403(自分で実装)

RLS 違反で詰まったとき、エラーコードが 401 なら「そもそも認証されていない」、403 なら「認証はされているが権限がない」と読み分けると原因特定が早くなります。

クライアント呼び出し時の再認証パターン

実運用では、以下のフローで Edge Functions を呼び出すと安定します。

async function callEdgeFunction<T>(
  functionName: string,
  body: Record<string, unknown>,
): Promise<T> {
  // 1. 現在のセッション取得(UX判定目的なので getSession() で十分)
  const { data: { session } } = await supabase.auth.getSession()

  // 2. セッションがない場合はリフレッシュを試みる
  if (!session) {
    const { data: refreshed, error } = await supabase.auth.refreshSession()
    if (error || !refreshed.session) {
      throw new Error('UNAUTHENTICATED')
    }
  }

  // 3. Edge Function 呼び出し(Authorization は自動付与される)
  const { data, error } = await supabase.functions.invoke<T>(functionName, {
    body,
  })

  // 4. 401 が返った場合は再度リフレッシュして1回だけリトライ
  //    (無限ループ防止のため、リトライは1回のみ)
  if (error?.status === 401) {
    await supabase.auth.refreshSession()
    const { data: retryData, error: retryError } = await supabase.functions.invoke<T>(
      functionName,
      { body },
    )
    if (retryError) throw retryError
    return retryData!
  }

  if (error) throw error
  return data!
}

anon / service_role / User JWT の使い分けチートシート

シナリオ 使うキー 理由
ログイン済みユーザーの操作 User JWT RLS でユーザー本人のデータのみ操作
未ログインユーザーの操作 anon key RLS は適用、公開データのみ
Webhook 受信(外部サービス) --no-verify-jwt + 独自検証 Supabase JWT を持っていないため
管理者操作・他ユーザーデータへのアクセス service_role(Edge Function 内のみ) RLS バイパス、絶対にクライアントに漏らさない
Push 通知送信・バッチ処理 service_role(Edge Function 内のみ) 複数ユーザーへの一括処理

まとめ

Supabase Edge Functions の 401 エラーは、パターンを把握すれば怖くありません。

  1. ヘッダーがないsupabase.functions.invoke() を使えば自動付与(accessToken ?? anonKey + apikey
  2. 未ログイン時functions.invoke() が anon key にフォールバック(手動なら Bearer <anon_key>
  3. DB 操作が失敗 → Edge Function 内で service_role キーを使う(RLS バイパス)
  4. セッション切れ → AppState の変化で自動リフレッシュ
  5. 外部 Webhook--no-verify-jwt + HMAC 署名検証 + タイムスタンプ窓 + 冪等管理
  6. 認可判断が必要getSession()(速度重視)か getUser()(本人性確認)を目的で使い分ける
  7. 401 と 403 の違い → 未認証=401、認証済みだが権限なし=403

認証エラーは地味に時間を奪われます。この記事が「あの 401、あのパターンか」と素早く解決できるリファレンスになれば幸いです。

この記事の実装を使っているアプリ

この記事で解説した Edge Functions の JWT 認証パターンは、育休中に開発した夫婦向け資産管理アプリ cotty Asset で実際に使っている実装をベースにしている。

Supabase の認証まわりにどっぷり浸かりながら開発していて、401 エラーに何度も悩まされた経験がこの記事につながっている。

もし興味があればぜひ使ってみてほしい。

cotty Asset - 夫婦の資産管理アプリ(iOS)
https://apps.apple.com/jp/app/cotty-asset/id6758410886

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?