401 や 403 を返す Webhook は、少数のパターンに収まります。多くの場合、署名の問題ですらありません——あなたのコードが走る前に、ミドルウェアやフレームワークの既定値がリクエストを拒否しているのです。これは私たちが使う診断チェックリストを、使う順番で並べたものです。
まず 401 と 403 を分ける
意味が違い、直し方も違います。
401 Unauthorized: サーバーはリクエストを受け取り、資格情報(または署名)を見て、受け入れませんでした。あなたのハンドラーはおそらく走り、期待署名を計算し、拒否しました。
403 Forbidden: サーバーはリクエストを受け取り、別の理由で処理を拒否しました。多くの場合リクエストはハンドラーに届かず——ミドルウェアやフレームワークの既定値が 403 を返しています。
トンネルのリクエストキャプチャを開き、レスポンスを見ます。ハンドラーのレスポンスボディ(「invalid signature」)が見えれば、あなたのコードからの 401 です。レスポンスが汎用的でハンドラーのログに何も出ないなら、ハンドラーが走る前にフレームワークが拒否しています。
最も可能性の高い 5 つの原因
1. CSRF ミドルウェア(Django, Rails, Laravel)
既定の CSRF 保護はセッショントークンのない POST を拒否します。Webhook プロバイダーは送りません。症状:403、汎用レスポンスボディ、ハンドラーログなし。
修正:Webhook ルートを CSRF 保護から除外。Django は @csrf_exempt。Rails は skip_before_action :verify_authenticity_token, only: [:webhook]。Laravel はパスを VerifyCsrfToken::$except に追加。Django Webhook ガイドが Django 版を最初から最後まで解説します。
2. 認証ミドルウェアが広すぎる適用
認証ミドルウェア(JWT、セッションチェック、API キー必須)をグローバルに追加し、Webhook ルートが継承しました。プロバイダーはあなたの認証ヘッダーを送らないので、ハンドラーが走る前にミドルウェアが 401 を返します。
修正:Webhook パスを認証ミドルウェアから除外。Webhook の認証は署名であって、API の残りを守るスキームではありません。
3. 署名検証の失敗
ハンドラーは走り、期待署名を計算し、一致しませんでした。頻度のおおむね降順で 5 つの副原因:
- 検証が走る前にミドルウェアがボディをパース(生ボディが消えた)。
- エンコーディング誤り(hex vs base64)。署名検証ガイドを参照。
- シークレット誤り(test vs live、dashboard vs CLI、env ファイル vs ランタイム)。
- タイムスタンプが古すぎる(署名は有効だが古い——おそらく古いリプレイ済みペイロードでテスト中)。
- env ファイルから読み込んだシークレット末尾の空白。
4. 登録されたトンネル URL が違う
トンネルを再起動して URL が変わったのに、プロバイダーのダッシュボードには古いものがまだあります。リクエストが届かないので 401 に見えますが、実際は別のリクエストが別のサーバー(多くは前のトンネルセッション、今は拒否または 401 を返す)に届いています。
修正:プロバイダーのダッシュボードの URL が現在のトンネルセッションと一致するか確認。安定 URL が要るなら、名前付きトンネルや予約サブドメインを検討。
5. CORS・content-type・メソッドの制限
Webhook では稀ですがあり得ます。ルートが application/json のみ受け付け、プロバイダーが application/x-www-form-urlencoded(例:Twilio)を送ると、一部のフレームワークは 415——設定が悪いと 403 もあり得ます。あるいはルートが GET 登録なのにプロバイダーが POST する場合。
90 秒の診断フロー
私たちが通る順番です:
- レスポンスボディを読む。 ハンドラーの言葉が入っていれば、リクエストはハンドラーに届いた。署名デバッグへ。汎用のフレームワークエラーページなら届いていない。ミドルウェアデバッグへ。
- ハンドラーログを確認。 ハンドラーのログ文が発火しているか? リクエストが届いたか確認できる。
- 登録 URL を確認。 プロバイダーのダッシュボードを開く。URL が現在のトンネルと一致し、正しいパスを指すか確認。
- 受信ヘッダーを比較。 トンネルキャプチャはプロバイダーが送った正確なヘッダーを見せる。あなたのコードが読むものと比較。ヘッダーは大小無視だがアクセス方法はフレームワークで異なる——一部は
request.headers.get('Stripe-Signature')、他はrequest.META['HTTP_STRIPE_SIGNATURE']。 - 署名時にボディが生か検証。 署名計算の直前にボディ長を出力。ゼロや妙に小さければミドルウェアが食べた。
- シークレットを再確認。 ランタイムの env 変数とダッシュボードを比較。
console.log(process.env.WEBHOOK_SECRET.length)——長さはダッシュボード表示と一致?
経験上、ステップ 1 と 5 が最初の 2 分で 80% のケースを捕まえます。
失敗リクエストをキャプチャ
ここで最も役立つツールは リクエストのキャプチャとリプレイつきトンネルです。プロバイダーの再試行を待つ必要はなく、デバッグ中にキャプチャ済みリクエストをローカルハンドラーへリプレイします。各試行は即座です。
リクエストをキャプチャしないトンネルを使っていると、片手を縛られてデバッグしているようなものです。キャプチャできるものへ切り替える(または tcpdump を回す、dev サーバーの前に nginx を置く)と、初めて 1 時間節約したときに元が取れます。
各プロバイダーから見た 401 の姿
- Stripe: あなたのコードからの 401 は署名検証失敗の意味。Stripe ダッシュボードは配信を失敗と表示し、あなたのレスポンスボディを含む。
- GitHub: ハンドラーが 401 を返すと、GitHub は配信を失敗とマークして再試行。最近の配信ページにレスポンスが出る。
- Shopify: Webhook ハンドラーからの 401 はセキュリティ上は問題ないが、Shopify は再試行する。48 時間で 19 回連続失敗すると、Shopify はサブスクリプションを無効化する。
より広い Webhook デバッグの文脈は Webhook をローカルでデバッグする方法を参照。キャプチャ内蔵のトンネルは PortPreview のウェイトリストへ。