署名検証
すべての Webhook 配信は、2 つのヘッダーを組み合わせて使います:
X-Signature: sha256=9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b
X-Timestamp: 1729536000sha256= の後の 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.parse の 前 に await req.text() を使用してください。
Express では Webhook ルートだけに
express.raw({ type: 'application/json' }) をマウントしてください。
実装例
Node / 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 のような重要イベントには推奨):
X-Deliveryでの重複排除。 一意制約付きのテーブルを使います。 許容ウィンドウ内のリプレイは no-op となり、ハンドラは作業を二度行わずに 200 を返します。これは正規のリトライにも欲しい冪等性そのものです。 (X-Deliveryは配信のすべてのリトライをまたいで安定しており、ペイロード にevent_idフィールドは含まれません。)- 時計のドリフトが許す最小の許容範囲を使う。 ±5 分が推奨デフォルトで、 NTP 同期されたほとんどのフリートで維持できる範囲です。それより厳しくしても 構いません; ±30 秒を下回ると、上流の NTP が遅いネットワークでは正規の 配信を拒否し始めます。
シークレットのローテーション
- ダッシュボード → Developers → Webhooks → [endpoint] → ⋮ → Rotate Secret。
- 新しいシークレットが生成され、1 度だけ 表示されます。ダイアログを 閉じる前にコピーしてください。
- 環境変数を更新し、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 フローから実際のイベントを 発火させてください。
よくある失敗
| 症状 | 考えられる原因 |
|---|---|
| 開発環境で常に false | HMAC 計算前に本文が JSON パースされている。先に生バイトを読んでください。 |
| 昨日まで動いていたが今日失敗する | シークレットをローテーションしたがこのサーバーの env var がまだ旧値。新しいシークレットで再デプロイしてください。 |
| 古いイベントで失敗、新しいイベントは成功 | ローテーション前にキューに入った配信です; 署名は旧シークレットを使い、検証側はもう受け入れません。リトライがドロップするのを待つか、ダッシュボードからリプレイしてください。 |
| タイムスタンプ比較のオフバイワン | Unix 秒同士で比較していることを確認してください。JS の Date.now() は ミリ秒 — 1000 で割ってください。 |
| ローカルでは動くが本番で失敗 | プロキシ (Cloudflare、nginx) が解凍、再エンコード、末尾改行の除去をしています。ハンドラに届くバイトを確認してください。 |
| テスト ping が 401 / 署名不一致 | 検証側が body のみで署名している (2026 年以前のスキーム)。timestamp + "." + body で署名するよう更新してください。 |
| ヘッダーが完全に欠落 | エンドポイントが別の環境に登録されています。Test モードのエンドポイントは environment=test イベントのみを受け取ります。 |