Webエンジニア向けプログラミング解説動画をYouTubeで配信中!
▶ チャンネル登録はこちら

【ITニュース解説】Stripe webhook returns 401 on a Supabase Edge Function? Here's the fix (and the step after it)

2026年10月19日に「Dev.to」が公開したITニュース「Stripe webhook returns 401 on a Supabase Edge Function? Here's the fix (and the step after it)」について初心者にもわかりやすく解説しています。

作成日: 更新日:

ITニュース概要

StripeのWebhookがSupabase Edge Functionで401エラーになるのは、SupabaseのデフォルトJWT認証が原因だ。該当関数のみ認証を無効にし、その後Stripe署名検証を実装してセキュリティを確保する。正しい署名シークレットの設定も忘れずに行う。

ITニュース解説

StripeのWebhookとSupabase Edge Functionを連携する際、Stripeのイベントが正しく機能に届かず、401エラーが発生する事態がある。具体的には、Stripe側の配信ログには401エラーが記録されているが、Supabase Edge Functionのログには何も表示されないという状況だ。これは、StripeからのWebhookリクエストが、Edge Functionのコードに到達する前にSupabaseプラットフォームによってブロックされていることを示唆している。

この401エラーの兆候は、Stripeダッシュボードで失敗した配信の詳細を確認することで判別できる。もしレスポンスボディに「Missing authorization header」や「Invalid Token or Protected Header formatting」といったメッセージが表示されていれば、リクエストはEdge Functionのハンドラーに到達していない可能性が高い。Supabaseのトラブルシューティングガイドによると、これらのメッセージはEdge Functionのコードが実行されるよりも前に動作する「従来の認証検証チェック」が原因で発生する。もしレスポンスボディが自分で書いたメッセージであるか、空である場合は、Edge Functionのコード自体が401エラーを返しているため、コード側を確認する必要がある。StripeのWebhookに関するドキュメントでも、宛先URLにアクセス制限がある場合に4xxエラーが発生することが明記されており、Webhookのエンドポイントはインターネットから公開アクセス可能である必要があるとされている。

この問題の根本原因は、Supabase Edge Functionがデフォルトで、リクエストの認証ヘッダーに有効なJWT(JSON Web Token)を要求するように設定されている点にある。StripeはWebhookリクエストを送信する際、Stripe-SignatureヘッダーやJSONボディは含めるが、Supabaseのユーザー認証に利用されるJWTは含まれていない。そのため、SupabaseプラットフォームはStripeからのリクエストを認証されていないと判断し、Edge Functionのコードが実行される前に、リクエストを401エラーで拒否してしまうのだ。Supabase自身も、StripeのWebhookはユーザーのトークンを持たないため、公開アクセス可能である必要があることを指摘している。

この状況を解決する最初のステップは、StripeからのWebhookイベントを受け取る特定のEdge Functionに対してのみ、JWT認証を無効にすることだ。これにはいくつかの方法がある。最も確実な方法は、プロジェクトのルートディレクトリにあるsupabase/config.tomlファイルに設定を追加することである。例えば、stripe-webhookという名前の関数であれば、次のように記述する。

1# supabase/config.toml
2[functions.stripe-webhook]
3verify_jwt = false

この設定はデプロイ後も維持されるため、推奨される。他のEdge FunctionはデフォルトのJWT認証設定のままで問題ない。また、Supabase CLIを使用して関数をデプロイする際に--no-verify-jwtフラグを付加することも可能だ。

1supabase functions deploy stripe-webhook --no-verify-jwt

このフラグはローカルでの開発環境で関数をテストする際にもsupabase functions serveコマンドと共に使用できる。さらに、Supabaseダッシュボードから該当するEdge Functionの詳細設定を開き、JWT検証のトグルをオフに切り替える方法もある。しかし、自動デプロイツールなどを使用している場合、このダッシュボードでの設定が再デプロイ時にリセットされてしまう可能性があるため、config.tomlでの設定も併用することが安全だ。

JWT認証を無効にすると、Edge Functionは公開アクセス可能となり、StripeからのWebhookリクエストを受け取れるようになる。しかし、これにより新たなセキュリティリスクが生じる。JWT認証がなければ、URLを知っている誰もが関数を呼び出せてしまうからだ。もしEdge Functionがリクエストボディの内容を無条件に信頼して処理を実行する設計になっている場合、悪意のあるユーザーが偽のWebhookイベントを送信することで、システムに不正な操作を行わせる恐れがある。

このため、JWT認証を無効にした後は、セキュリティを確保するためにStripeが提供する署名を検証するステップが必須となる。Supabaseも、外部のWebhookプロバイダを利用する際は、認証をスキップし、関数内でプロバイダの署名を検証することを推奨している。

Stripeの署名をDeno環境で正しく検証するためには、特に二つの点に注意が必要だ。一つは、リクエストの生のボディを使用すること。Stripeの署名検証は、リクエストの「生の(raw)ボディ」に対して行われるため、リクエストを受信したらawait req.text()を使ってボディをテキストとして読み込む必要がある。req.json()を先に呼び出してJSONとしてパースしてしまうと、ボディの内容が変更され、署名検証が失敗してしまう。もう一つは、非同期の署名検証機能を使用すること。stripe-nodeライブラリのSubtleCryptoProviderは、DenoのWeb Crypto APIを利用するため、非同期操作のみをサポートしている。したがって、stripe.webhooks.constructEventではなく、stripe.webhooks.constructEventAsyncをStripe.createSubtleCryptoProvider()と一緒に使用する必要がある。同期パスを使うとエラーが発生する。

Supabaseの公式Stripe Webhook例は、これらの点を踏まえた実装になっている。

1// supabase/functions/stripe-webhook/index.ts
2import Stripe from 'npm:stripe@^22'
3import { withSupabase } from 'npm:@supabase/server@^1'
4
5const stripe = new Stripe(Deno.env.get('STRIPE_API_KEY') as string)
6const cryptoProvider = Stripe.createSubtleCryptoProvider()
7
8export default {
9  fetch: withSupabase({ auth: 'none' }, async (req) => {
10    const signature = req.headers.get('Stripe-Signature')
11    const body = await req.text() // 生のボディを読み込む
12
13    let event
14    try {
15      event = await stripe.webhooks.constructEventAsync(
16        body,
17        signature!,
18        Deno.env.get('STRIPE_WEBHOOK_SIGNING_SECRET')!,
19        undefined,
20        cryptoProvider,
21      )
22    } catch (err) {
23      return new Response((err as Error).message, { status: 400 })
24    }
25
26    // ここでevent.typeに基づいて処理を行う
27    return Response.json({ ok: true })
28  }),
29}

このコードでは、req.text()で生のボディを取得し、constructEventAsyncとcryptoProviderを組み合わせて署名検証を行っている。検証に失敗した場合は、400エラーを返すことで不正なリクエストを拒否し、セキュリティを確保している。

JWT認証を無効にし、Stripeの署名検証を実装した後も、Webhookが400エラーを返す場合がある。これは通常、コードは実行されているが、署名が一致しないことを意味する。この問題は、使用しているStripeの署名秘密鍵が正しくないことが主な原因だ。

Stripeは、Webhookエンドポイントごとに一意の秘密鍵を生成する。また、テスト環境とライブ環境で同じエンドポイントを使用する場合でも、それぞれの環境で異なる秘密鍵が発行される点に注意が必要だ。さらに、stripe listenコマンドを実行した際に表示される秘密鍵は、ローカルでのフォワードテスト専用のものであり、実際にデプロイされたEdge Functionのエンドポイントには使用できない。

デプロイされたEdge Functionに正しい秘密鍵を設定するには、Supabase CLIを使用するか、ダッシュボードのEdge Function Secretsセクションで設定を行う。

1supabase secrets set STRIPE_WEBHOOK_SIGNING_SECRET=whsec_...

Supabaseの秘密鍵に関するドキュメントによると、新しい秘密鍵は関数に即座に読み込まれるため、再デプロイは不要である。ライブ環境に移行する際は、必ずライブ環境のエンドポイントに割り当てられた秘密鍵に更新する必要がある。テスト環境の秘密鍵では、ライブイベントを検証することはできない。

これらの設定が正しく行われたかを確認するには、いくつかの方法がある。まず、curlコマンドを使って、WebhookのURLに対して署名なしのPOSTリクエストを送信してみる。

1curl -i -X POST -H 'content-type: application/json' -d '{}' \
2https://<project-ref>.supabase.co/functions/v1/<your-function-name>

このリクエストに対して401エラー(「Missing authorization header」など)が返ってきた場合、まだJWT認証が無効になっていないため、解決策1に戻って確認が必要だ。もし400エラーが返ってきた場合(公式例では「No stripe-signature header value was provided.」といったメッセージが期待される)、関数はアクセス可能であり、署名なしのイベントを正しく拒否していることを意味する。これは望ましい状態である。もし200が返ってきた場合、関数はアクセス可能だが、署名なしのイベントも受け入れてしまっているため、解決策2に戻って署名検証の実装を確認する必要がある。

次に、Stripeダッシュボードから失敗したイベントを再送し、配信ログが2xx(成功)を示すことを確認する。Stripeのドキュメントによると、ダッシュボードからのイベント再送はイベント作成後15日間可能であり、ライブモードではStripe自体が最大3日間リトライを試みる。

これらのステップを通して、Stripe WebhookとSupabase Edge Functionの連携における401エラー問題を解決し、同時にセキュリティも確保できる。正確な設定と実装が重要であるため、一つ一つの項目を丁寧に確認し、適切に作業を進めることが推奨される。

関連コンテンツ

関連IT用語

関連ITニュース