はじめに
Supabase Edge Functions を実装していると、必ずといっていいほど出くわすのが 401 Unauthorized や Invalid 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 でエラーが返ったとき、401 と 403 は別の意味を持ちます。
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 エラーは、パターンを把握すれば怖くありません。
-
ヘッダーがない →
supabase.functions.invoke()を使えば自動付与(accessToken ?? anonKey+apikey) -
未ログイン時 →
functions.invoke()が anon key にフォールバック(手動ならBearer <anon_key>) -
DB 操作が失敗 → Edge Function 内で
service_roleキーを使う(RLS バイパス) - セッション切れ → AppState の変化で自動リフレッシュ
-
外部 Webhook →
--no-verify-jwt+ HMAC 署名検証 + タイムスタンプ窓 + 冪等管理 -
認可判断が必要 →
getSession()(速度重視)かgetUser()(本人性確認)を目的で使い分ける - 401 と 403 の違い → 未認証=401、認証済みだが権限なし=403
認証エラーは地味に時間を奪われます。この記事が「あの 401、あのパターンか」と素早く解決できるリファレンスになれば幸いです。
この記事の実装を使っているアプリ
この記事で解説した Edge Functions の JWT 認証パターンは、育休中に開発した夫婦向け資産管理アプリ cotty Asset で実際に使っている実装をベースにしている。
Supabase の認証まわりにどっぷり浸かりながら開発していて、401 エラーに何度も悩まされた経験がこの記事につながっている。
もし興味があればぜひ使ってみてほしい。
cotty Asset - 夫婦の資産管理アプリ(iOS)
https://apps.apple.com/jp/app/cotty-asset/id6758410886