Skip to Content
Webhook署名検証

署名検証

すべての Webhook 配信は、2 つのヘッダーを組み合わせて使います:

X-Signature: sha256=9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b X-Timestamp: 1729536000

sha256= の後の 16 進文字列は HMAC-SHA256(secret, timestamp + "." + raw_body) です。 ドットはリテラルなバイト、タイムスタンプは ASCII の Unix 秒です。

なぜ検証するのか

Webhook URL は漏れます。プロキシのログ、スクリーンショット、ブラウザ履歴、 パートナーのサポートチケットなどに現れます。署名チェックがなければ、 URL を知った誰でも偽の payment.settled イベントを POST して、 未払いの注文をフルフィルさせることができてしまいます。検証は、 リクエストが本当に InfraIO から来たことを暗号的に証明します。

タイムスタンプを署名対象に含めることで、リプレイ対策 も提供されます: 配信を捕捉した攻撃者でも、許容ウィンドウを過ぎると署名が古くなり 検出できるようになります。

アルゴリズム

signed_payload = timestamp + "." + raw_body expected_header = "sha256=" + lowercase_hex( HMAC_SHA256(secret, signed_payload) ) constant_time_compare(expected_header, x_signature_header)

その後、タイムスタンプが新しいことを確認します (一般的な許容範囲: ±5 分)。

常に のリクエスト本文バイトを渡してください。フレームワークは ハンドラが動く前に JSON をパースすることが多く、再シリアライズした ものは送信したものと異なる場合があります (キーの順序、空白、数値の フォーマット)。すると HMAC が一致しません。Next.js App Router では JSON.parseawait req.text() を使用してください。 Express では Webhook ルートだけに express.raw({ type: 'application/json' }) をマウントしてください。

実装例

lib/verify-infraio.ts
import { createHmac, timingSafeEqual } from "node:crypto"; const TOLERANCE_SECONDS = 5 * 60; export function verifyInfraIo({ body, signature, timestamp, secret, }: { body: string; // 生テキスト — パース済み JSON ではない signature: string; // X-Signature ヘッダーの値 timestamp: string; // X-Timestamp ヘッダーの値 (Unix 秒) secret: string; // whsec_… }): boolean { const ts = Number.parseInt(timestamp, 10); if (!Number.isFinite(ts)) return false; if (Math.abs(Math.floor(Date.now() / 1000) - ts) > TOLERANCE_SECONDS) { return false; // 古すぎ、または未来すぎる } const expected = "sha256=" + createHmac("sha256", secret) .update(`${timestamp}.${body}`) .digest("hex"); const a = Buffer.from(signature); const b = Buffer.from(expected); return a.length === b.length && timingSafeEqual(a, b); }

リプレイ対策

署名ペイロードに含まれるタイムスタンプが 最前線 の防御です — 配信を 捕捉した攻撃者でも、許容ウィンドウを過ぎた後はリプレイできません。

念のための二重対策 (payment.settled のような重要イベントには推奨):

  1. X-Delivery での重複排除。 一意制約付きのテーブルを使います。 許容ウィンドウ内のリプレイは no-op となり、ハンドラは作業を二度行わずに 200 を返します。これは正規のリトライにも欲しい冪等性そのものです。 (X-Delivery は配信のすべてのリトライをまたいで安定しており、ペイロード に event_id フィールドは含まれません。)
  2. 時計のドリフトが許す最小の許容範囲を使う。 ±5 分が推奨デフォルトで、 NTP 同期されたほとんどのフリートで維持できる範囲です。それより厳しくしても 構いません; ±30 秒を下回ると、上流の NTP が遅いネットワークでは正規の 配信を拒否し始めます。

シークレットのローテーション

  1. ダッシュボード → Developers → Webhooks → [endpoint] → ⋮ → Rotate Secret。
  2. 新しいシークレットが生成され、1 度だけ 表示されます。ダイアログを 閉じる前にコピーしてください。
  3. 環境変数を更新し、24 時間以内に検証側を再デプロイしてください。

猶予ウィンドウ (デュアル署名)

ローテーション後の 24 時間 は、すべての配信に 2 つの署名が付きます:

X-Signature: sha256=<hmac(new_secret, ts + "." + body)> X-Signature-Prev: sha256=<hmac(prev_secret, ts + "." + body)> X-Timestamp: 1729536000

前の シークレットを使う検証側は X-Signature-Prev に一致し、 新しい シークレットを使う検証側は X-Signature に一致します。 どちらかが通れば十分 — ハンドラは移行中もデプロイを止めずに配信を 受け入れられます。

猶予ウィンドウが閉じると X-Signature のみが送られるようになります。 前のシークレットは受け入れられなくなり、まだそれで設定されている 検証側は配信を拒否し始めます — 24 時間の予算内にロールアウトを 終わらせてください。

推奨される受信側パターン

// ローテーション猶予ウィンドウ中はどちらの署名も受け入れる。 const sig = req.headers["x-signature"] ?? ""; const sigPrev = req.headers["x-signature-prev"] ?? ""; const ok = verify(body, sig, ts, CURRENT_SECRET) || (PREV_SECRET && verify(body, sigPrev, ts, PREV_SECRET));

エンドポイントの猶予ウィンドウが切れて、PREV_SECRET を env から 削除し終えたら、X-Signature-Prev のブランチも削除して構いません。

緊急失効

シークレットが公に漏れて前のシークレットを即座に無効化したい場合 — つまり 24 時間オーバーラップで既知の不正鍵が生き続けるのを避けたい場合 — 2 回ローテーションしてください。1 回目のローテーションで漏れたシークレットが prev スロットに移り、2 回目のローテーションで prev スロットから押し出され (新しい鍵で置き換えられ)、漏れた値は受け入れられなくなります。

配線テスト

ダッシュボードで Developers → Webhooks を開き、検証したいエンドポイントの Send Test をクリックします。同期的に URL へエンベロープに署名して POST し、HTTP ステータス、レイテンシ、応答の先頭 512 バイトを表示します。 ペイロードの形状:

{ "event_id": "<uuid>", "event_type": "webhook.test.ping", "created_at": "2026-05-17T12:00:00Z", "test": true, "data": { "merchant_id": "<your-merchant-id>", "webhook_id": "<endpoint-id>", "message": "Test ping from the merchant dashboard..." } }

テスト ping は本番配信と同じ署名スキームを使うので、このボタンで グリーンチェックが出れば検証側が本物のイベントも受け入れることが 確認できます。テスト ping は RMQ リトライパイプラインをバイパスします — リトライを動かしたい場合は対応する API フローから実際のイベントを 発火させてください。

よくある失敗

症状考えられる原因
開発環境で常に falseHMAC 計算前に本文が JSON パースされている。先に生バイトを読んでください。
昨日まで動いていたが今日失敗するシークレットをローテーションしたがこのサーバーの env var がまだ旧値。新しいシークレットで再デプロイしてください。
古いイベントで失敗、新しいイベントは成功ローテーション前にキューに入った配信です; 署名は旧シークレットを使い、検証側はもう受け入れません。リトライがドロップするのを待つか、ダッシュボードからリプレイしてください。
タイムスタンプ比較のオフバイワンUnix 秒同士で比較していることを確認してください。JS の Date.now()ミリ秒 — 1000 で割ってください。
ローカルでは動くが本番で失敗プロキシ (Cloudflare、nginx) が解凍、再エンコード、末尾改行の除去をしています。ハンドラに届くバイトを確認してください。
テスト ping が 401 / 署名不一致検証側が body のみで署名している (2026 年以前のスキーム)。timestamp + "." + body で署名するよう更新してください。
ヘッダーが完全に欠落エンドポイントが別の環境に登録されています。Test モードのエンドポイントは environment=test イベントのみを受け取ります。