Webhook署名検証の失敗の多くは暗号のバグではありません。パースのバグです。誰かが検証前にボディをパースした。誰かがプロバイダーがbase64を求めているのにhexを使った。誰かがタイミングセーフ比較が必要なのに===で比較した。このガイドは、私たちがこれまでデバッグしたすべての署名問題の短縮版です。
プロバイダーが使うモデル
主要なWebhookプロバイダー — Stripe、GitHub、Shopify、Twilio、Slack、Discord — はどれも、見た目を変えながら同じことをしています。
- あなたとプロバイダーがシークレットを共有する。
- プロバイダーがリクエストボディ(ときにタイムスタンプ、ときにURLを加えて)に対する署名を計算する。
- 署名はヘッダーに乗って届く。
- あなたのハンドラが同じシークレットで署名を再計算し、一致しなければリクエストを拒否する。
プロバイダー間で変わるもの: アルゴリズム(HMAC SHA-256が最も一般的、DiscordはEd25519)、エンコーディング(hex対base64)、署名対象(ボディ、ボディ+タイムスタンプ、URL+ボディ+パラメータ)、署名を運ぶヘッダー。
ほぼすべてを占める4つのバグ
1. ボディを先にパースする
多くのフレームワークには、受信ボディをオブジェクトにパースするJSONミドルウェアがあります。それが走った瞬間に生のバイト列は失われ、あなたが計算する署名は一致しません — パース済みオブジェクトを再度文字列化しても、空白やキーの順序が異なり得ます。
解決策: Webhookルートでは、どのミドルウェアよりも先に生のボディを読む。Expressではexpress.raw({ type: 'application/json' })をWebhookパスにのみマウントする。Next.js App Routerではawait request.text()を直接読む。Djangoでは、DRFパーサーが走る前に@csrf_exempt + request.bodyを使う。
2. 誤ったエンコーディング
StripeとGitHubはhex。Shopifyはbase64。同じシークレットでも、それぞれ見た目の異なる出力になります。これはShopifyガイドで扱いました — 誰もが引っかかります。
3. タイミングセーフでない
文字列の等価比較(===)は最初に一致しない文字で早期に返ります。「最初の1文字が一致」と「64文字すべて一致」の時間差は、シークレットに関する情報を漏らします。タイミングセーフ比較を使いましょう。
// Node
crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
// Python
hmac.compare_digest(a, b)
// Ruby
ActiveSupport::SecurityUtils.secure_compare(a, b)
実際には、公開インターネット越しのWebhookエンドポイントに対してこの攻撃を仕掛けるのは難しいですが、セキュアな比較は非常に安価なので、使わない理由はありません。
4. タイムスタンプ未検証
署名がボディしかカバーしていないと、有効なリクエストを捕捉した攻撃者はそれを永遠にリプレイできます。プロバイダーは署名ペイロードにタイムスタンプを含め、ドキュメントに許容ウィンドウを示すことで対処します(Stripe: 5分、GitHub: こちらも短い)。ハンドラは許容範囲より古いタイムスタンプの署名をすべて拒否すべきです。
この手順を飛ばせば、HMACがどれほど完璧でも、リプレイに弱いシステムを作ったことになります。
ヘッダーに実際に入っているもの
プロバイダーのヘッダーは「署名」だけでなくもっと多くを運びます。Stripeはt=1234567890,v1=abcdef...,v0=...を送ります — タイムスタンプ+複数の署名バージョン。GitHubは接頭辞付きのsha256=abcdef...を送ります。TwilioはリクエストURLを署名ペイロードに含めますが、署名自体はX-Twilio-Signatureでのみ送ります。統合するプロバイダーのドキュメントを読み、その正確なフォーマットに対して実装しましょう。
ローカルテストでデバッグ可能になる
署名検証は最悪の種類のバグです。静かで、ステージングでは再現しづらい。トンネルとリクエスト捕捉によるローカルテストは、失敗した正確なペイロード、届いた正確なヘッダー、そしてプロバイダーを煩わせずに修正を再テストできるリプレイボタンを与えてくれます。
検証が何かを拒否したら、デバッグに足る情報をログに残しましょう。生ボディの長さ、読んだヘッダー、計算した署名。シークレットはログに残さないこと。(シークレットをログに残す人を見たことがあります。やめましょう。)
最小限の正しい検証器
// Express + Node, generic HMAC SHA-256 hex provider
app.post(
'/webhooks/foo',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.headers['x-foo-signature'];
const timestamp = req.headers['x-foo-timestamp'];
// 1. Reject old timestamps (5 min tolerance)
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return res.status(400).send('stale');
}
// 2. Compute expected signature
const expected = crypto
.createHmac('sha256', process.env.FOO_SECRET)
.update(`${timestamp}.${req.body}`)
.digest('hex');
// 3. Timing-safe compare
if (!crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature),
)) {
return res.status(401).send('invalid');
}
// 4. Now safe to parse and process
const event = JSON.parse(req.body);
handleEvent(event);
res.status(200).end();
},
);
それでも署名検証が失敗するとき
上記がすべて正しいのに失敗するなら、さらに3つ確認を: シークレットにenvファイル由来の末尾空白がある、トンネルがヘッダーを書き換えた(ほとんどはしませんが、リクエスト捕捉で確認する価値あり)、あるいはダッシュボードがテストシークレットを表示しているのに本番シークレットでテストしている。3つとも経験済みです。
プロバイダー固有の落とし穴は、Stripe、GitHub、Shopifyのガイドを参照。あるいは今まさに401を見ているなら、Webhookの401エラーをデバッグするへ。これをデバッグ可能にするトンネル+捕捉のためにPortPreviewのウェイトリストに登録してください。