Webhook解説:署名付きペイロードと信頼性の高いリトライ

Backend2026-11-05TryQuickToolBox

ステージング環境では連携はうまく動いていました。決済プロバイダーがイベントを発火し、エンドポイントがそれを受信し、注文ステータスがpaidに変わります。しかし本番環境では、顧客が二重に支払い、デプロイ中に配信が静かに消え、誰かがキャプチャしたリクエストをリプレイして注文を返金済みにマークします。これら3つの障害はすべて同じ根本原因を共有しています。Webhookがセキュリティ上重要なat-least-once配信チャネルではなく、単なるHTTP POSTとして扱われていたのです。

このガイドでは、正しいWebhook実装の2つの側面、つまりペイロードが真正であることを検証することと、リトライを経ても配信が生き残るようにすることについて、どのスタックにも適応できるコードとともに解説します。

Webhookとは本当は何か

Webhookとは、あるシステムがイベント発生時に別のシステムへ送信するサーバー間のHTTPリクエストです。これは通常のポーリングモデルを反転させます。アプリが毎分「何か新しいことは?」と尋ねる代わりに、プロデューサーが登録されたURLへイベントをプッシュします。

この設計から2つの性質が導かれ、どちらも重要です:

以下のすべては、これら2つの事実の帰結です。

HMACによるペイロードの署名

標準的なアプローチは、共有シークレットと生のリクエストボディに対するHMACです。送信側が署名を計算してヘッダーに入れ、受信側が再計算して比較します。一致すれば、ペイロードはシークレットを知る誰かから来たものであり、転送中に改ざんされていないことがわかります。

動作する実装と壊れた実装を分ける3つの詳細があります:

  1. 生のバイト列に署名する。 JSONのパースは検証後に行ってください。ボディを再シリアライズすると空白やキーの順序が変わり、署名が決して一致しなくなります。
  2. 定数時間比較を使用する。 素朴な==はタイミング情報を漏洩します。言語組み込みの安全な比較を使用してください。
  3. 署名対象の文字列にタイムスタンプを含める。 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つのルールが健全性を保ちます:

ハンドラーを冪等にする

配信が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を使用します。

本番稼働前のチェックリスト

  1. 生のバイト列に対するHMACを定数時間比較で検証する。
  2. 数分より古いタイムスタンプを拒否してリプレイを阻止する。
  3. イベントを永続化してから、1〜2秒以内に2xxを返す。
  4. 処理を行う前にイベントIDで重複排除する。
  5. イベントID、タイプ、結果をログに記録する。シークレットは決して記録しない。
  6. ダッシュボードや管理ツールから手動でイベントをリプレイする方法を提供する。
  7. 意図的に壊した署名、重複配信、遅いハンドラーでテストする。

失敗している連携をデバッグするとき、最もじっくり見ることになるのは生のリクエストボディです。それをJSONフォーマッターに貼り付ければ、すでにキャプチャしたバイト列を変更せずに、ネストされたイベントオブジェクトを読みやすくできます。

FAQ

ペイロードが同一に見えるのに署名検証が失敗するのはなぜですか?

ほとんどの場合、ハッシュ化の前にボディがパースされ再シリアライズされたためです。JSONのキー順序、空白、Unicodeエスケープはパース/文字列化の過程で変化するため、バイト列が送信側が署名したものと一致しなくなります。生のボディをキャプチャしてそれをハッシュ化してください。

イベント処理の前と後、どちらで200を返すべきですか?

永続的に保存した後、重い処理を行う前に返します。先に処理すると、遅いダウンストリーム呼び出しがタイムアウトを引き起こし、送信側がリトライして重複が発生します。イベントをキューに入れ、すぐに応答し、非同期で処理します。

HTTPSだけでWebhookエンドポイントを保護するのに十分ですか?

いいえ。TLSは転送中のデータを保護しますが、誰がリクエストを送信したかを証明するものではありません。誰でも公開URLにPOSTできます。共有シークレットによるHMAC署名は送信者を認証し、署名付きタイムスタンプはキャプチャされたリクエストのリプレイを防ぎます。

まとめ

Webhookを他の信頼できない入力と同様に扱いましょう。生のバイト列に対して署名を検証し、タイムスタンプを確認し、イベントIDで重複排除し、イベントが安全に保存された後にのみ確認応答します。そうすれば、リトライは二重請求の原因ではなくなり、本来の目的であるセーフティネットになります。