Webhook解説:署名付きペイロードと信頼性の高いリトライ
ステージング環境では連携はうまく動いていました。決済プロバイダーがイベントを発火し、エンドポイントがそれを受信し、注文ステータスがpaidに変わります。しかし本番環境では、顧客が二重に支払い、デプロイ中に配信が静かに消え、誰かがキャプチャしたリクエストをリプレイして注文を返金済みにマークします。これら3つの障害はすべて同じ根本原因を共有しています。Webhookがセキュリティ上重要なat-least-once配信チャネルではなく、単なるHTTP POSTとして扱われていたのです。
このガイドでは、正しいWebhook実装の2つの側面、つまりペイロードが真正であることを検証することと、リトライを経ても配信が生き残るようにすることについて、どのスタックにも適応できるコードとともに解説します。
Webhookとは本当は何か
Webhookとは、あるシステムがイベント発生時に別のシステムへ送信するサーバー間のHTTPリクエストです。これは通常のポーリングモデルを反転させます。アプリが毎分「何か新しいことは?」と尋ねる代わりに、プロデューサーが登録されたURLへイベントをプッシュします。
この設計から2つの性質が導かれ、どちらも重要です:
- エンドポイントは公開されている。 インターネット上の誰でもPOSTできるため、すべてのリクエストを認証する必要があります。
- 配信はat-least-onceである。 ネットワークは失敗し、サーバーは再起動し、タイムアウトは曖昧です。送信側はハンドラーが実行されたかどうかを知ることができません。そのためリトライが行われ、同じイベントを複数回受け取る可能性があります。
以下のすべては、これら2つの事実の帰結です。
HMACによるペイロードの署名
標準的なアプローチは、共有シークレットと生のリクエストボディに対するHMACです。送信側が署名を計算してヘッダーに入れ、受信側が再計算して比較します。一致すれば、ペイロードはシークレットを知る誰かから来たものであり、転送中に改ざんされていないことがわかります。
動作する実装と壊れた実装を分ける3つの詳細があります:
- 生のバイト列に署名する。 JSONのパースは検証後に行ってください。ボディを再シリアライズすると空白やキーの順序が変わり、署名が決して一致しなくなります。
- 定数時間比較を使用する。 素朴な
==はタイミング情報を漏洩します。言語組み込みの安全な比較を使用してください。 - 署名対象の文字列にタイムスタンプを含める。
timestamp + "." + bodyに署名することで、古いリクエストを拒否でき、リプレイ攻撃を防げます。
Node.jsでの署名検証
const crypto = require('crypto');
function verifyWebhook(rawBody, headers, secret, toleranceSeconds = 300) {
const signature = headers['x-signature']; // e.g. "t=1712345678,v1=abc123..."
const timestamp = headers['x-timestamp'];
if (!signature || !timestamp) return false;
// 1. Reject stale requests (replay protection)
const age = Math.floor(Date.now() / 1000) - Number(timestamp);
if (!Number.isFinite(age) || age > toleranceSeconds) return false;
// 2. Recompute over the exact bytes received
const signedPayload = `${timestamp}.${rawBody}`;
const expected = crypto
.createHmac('sha256', secret)
.update(signedPayload, 'utf8')
.digest('hex');
// 3. Constant-time compare
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(signature, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Expressでは、Webhookルートにexpress.raw({ type: 'application/json' })が必要で、ボディがBufferとして届くようにします。グローバルのexpress.json()ミドルウェアが先に実行されると生のボディが失われ、検証が失敗します。これは非常によくあるバグです。
Pythonでの検証
import hmac, hashlib, time
def verify(raw_body: bytes, timestamp: str, signature: str, secret: bytes, tolerance: int = 300) -> bool:
if abs(time.time() - int(timestamp)) > tolerance:
return False
signed = timestamp.encode() + b"." + raw_body
expected = hmac.new(secret, signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
検証失敗時は常に401を返し、リクエストIDをログに記録してください。ただし、シークレットや完全な署名は決してログに記録しないでください。
実際に機能するリトライの設計
リトライは、エンドポイントが予測可能に動作する場合にのみ有用です。契約はシンプルです。イベントを永続的に受け入れたらすぐに2xxを返し、それ以外はすべてリトライさせます。
| あなたのレスポンス | 送信側の動作 | 代わりにすべきこと |
|---|---|---|
200 / 204 | 配信済みとマーク | 正しい — キュー投入後に確認応答 |
400 / 401 | 通常リトライしない | 署名またはペイロード処理を修正 |
429 / 503 | バックオフ付きでリトライ | 過負荷時に返す。汎用の500ではない |
500 / タイムアウト | リトライ | 遅い処理をキューに移動 |
2つのルールが健全性を保ちます:
- 素早く確認応答する。 検証し、イベントをキューやテーブルに永続化し、
200を返します。実際の処理は非同期で行います。応答前に3つのAPIを呼び出すハンドラーはタイムアウトし、重複配信を引き起こします。 - ジッター付きの指数バックオフを使用する。 送信側が即座にリトライすると、短時間の障害がサンダリングハードになります。10秒、30秒、2分、10分、1時間のようなスケジュールにランダムなジッターを加えると、負荷が分散されます。
ハンドラーを冪等にする
配信がat-least-onceであるため、同じイベントIDが2回届く可能性があります。1回目はハンドラーが実際に成功したタイムアウト後です。処理済みのイベントIDを保存し、重複をスキップします:
INSERT INTO processed_events (event_id, received_at)
VALUES ($1, now())
ON CONFLICT (event_id) DO NOTHING;
-- If 0 rows inserted, this is a duplicate: return 200 and stop.
可能な場合は、ビジネス操作自体を冪等にします。カウンターをインクリメントするのではなく注文ステータスをpaidに設定し、ダウンストリームサービスを呼び出す際の冪等性キーとしてイベントIDを使用します。
本番稼働前のチェックリスト
- 生のバイト列に対するHMACを定数時間比較で検証する。
- 数分より古いタイムスタンプを拒否してリプレイを阻止する。
- イベントを永続化してから、1〜2秒以内に
2xxを返す。 - 処理を行う前にイベントIDで重複排除する。
- イベントID、タイプ、結果をログに記録する。シークレットは決して記録しない。
- ダッシュボードや管理ツールから手動でイベントをリプレイする方法を提供する。
- 意図的に壊した署名、重複配信、遅いハンドラーでテストする。
失敗している連携をデバッグするとき、最もじっくり見ることになるのは生のリクエストボディです。それをJSONフォーマッターに貼り付ければ、すでにキャプチャしたバイト列を変更せずに、ネストされたイベントオブジェクトを読みやすくできます。
FAQ
ペイロードが同一に見えるのに署名検証が失敗するのはなぜですか?
ほとんどの場合、ハッシュ化の前にボディがパースされ再シリアライズされたためです。JSONのキー順序、空白、Unicodeエスケープはパース/文字列化の過程で変化するため、バイト列が送信側が署名したものと一致しなくなります。生のボディをキャプチャしてそれをハッシュ化してください。
イベント処理の前と後、どちらで200を返すべきですか?
永続的に保存した後、重い処理を行う前に返します。先に処理すると、遅いダウンストリーム呼び出しがタイムアウトを引き起こし、送信側がリトライして重複が発生します。イベントをキューに入れ、すぐに応答し、非同期で処理します。
HTTPSだけでWebhookエンドポイントを保護するのに十分ですか?
いいえ。TLSは転送中のデータを保護しますが、誰がリクエストを送信したかを証明するものではありません。誰でも公開URLにPOSTできます。共有シークレットによるHMAC署名は送信者を認証し、署名付きタイムスタンプはキャプチャされたリクエストのリプレイを防ぎます。
まとめ
Webhookを他の信頼できない入力と同様に扱いましょう。生のバイト列に対して署名を検証し、タイムスタンプを確認し、イベントIDで重複排除し、イベントが安全に保存された後にのみ確認応答します。そうすれば、リトライは二重請求の原因ではなくなり、本来の目的であるセーフティネットになります。