署名を検証せずに Webhook を処理しているなら、インターネット上の誰でも、エンドポイントに偽の payment_intent.succeeded を送れます。これは理論上の話ではありません。Webhook 連携で最もよくある認証の穴で、攻撃者もそれを知っています。
この記事は、現場のエンジニアのための署名検証ガイドです。各プロバイダーが何をしているか、実装がつまずく微妙なポイント、リトライ時のタイムスタンプをきれいに扱う方法を説明します。
TL;DR
- Stripe、GitHub、Shopify はいずれも、生のボディのバイト列に対する HMAC-SHA256 を使います。ハッシュの前に JSON を再シリアライズしないでください
- Stripe は
Stripe-Signatureに、タイムスタンプtと、hex エンコードされた署名v1を入れて送ります。タイムスタンプの許容範囲は 5 分です - GitHub は
X-Hub-Signature-256にsha256=…を入れて送ります。タイムスタンプはありません - Shopify は
X-Shopify-Hmac-SHA256に base64 の HMAC-SHA256 を入れて送ります - 比較には必ず定数時間比較(
crypto.timingSafeEqual)を使います。===は決して使いません - リトライでタイムスタンプのチェックが壊れるなら、配信試行のたびに再署名する中継サービスが必要です
プロバイダーが Webhook に署名する理由
Webhook の URL は公開されています。URL を知っていれば、何でもそこに POST できます。署名が証明するのは 2 つのことです。リクエストが攻撃者ではなくプロバイダーから来たこと、そしてボディが転送中に改ざんされていないことです。
署名が何を証明しないかも、正確に押さえておく価値があります。ここでテストがすべて緑でも、見た目ほど強い証拠にはならないからです。「Webhook の署名検証テストは何も証明していない」では、攻撃者が素通りできる検証コードに対しても通ってしまうテストフィクスチャを紹介しています。また署名は、イベントがそもそも届いたかどうかについては何も語りません。だからこそ、取りこぼした失効の Webhook はグラフの欠けではなくセキュリティインシデントです。そして、実際に届いたものを記録するログは、改ざんを検知できるようにしておく価値があります。
検証がなければ、Webhook の URL を推測したり見つけたりした人は、偽のイベントを送れます。検証があれば、署名シークレットも必要になります。それを持っているのは、受信側とプロバイダーだけです。
Stripe
Stripe は次のような Stripe-Signature ヘッダーを送ります。
t=1617223220,v1=3b8a92b...,v1=9d7ac...
t は Webhook が送信された時刻のタイムスタンプです。v1 は {timestamp}.{raw_body} の HMAC-SHA256 を hex エンコードしたものです。キーのローテーション中は、v1 が複数あります。
検証の手順は次のとおりです。
- ヘッダーから
tとv1の値を取り出します tが約 5 分より古ければ拒否します(リプレイ攻撃の防止)HMAC_SHA256(signing_secret, t + "." + raw_body)を計算します- 各
v1と定数時間で比較し、どれか 1 つでも一致すれば受け入れます
import crypto from "node:crypto";
// timingSafeEqual throws on inputs of different lengths, so check that first
function safeEqual(a: string, b: string): boolean {
const x = Buffer.from(a);
const y = Buffer.from(b);
return x.length === y.length && crypto.timingSafeEqual(x, y);
}
function verifyStripe(body: string, header: string, secret: string): boolean {
const parts = Object.fromEntries(
header.split(",").map((p) => p.split("=").slice(0, 2) as [string, string])
);
const t = parts.t;
const v1s = header.match(/v1=([a-f0-9]+)/g)?.map((x) => x.slice(3)) ?? [];
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${body}`)
.digest("hex");
return v1s.some((sig) => safeEqual(expected, sig));
}
ここでよくある間違いは、先にボディを JSON としてパースし、それを文字列に戻すことです。そうすると空白が変わります。署名は生のバイト列に対するものなので、受け取ったバイト列そのもので検証しなければなりません。
GitHub
GitHub は X-Hub-Signature(SHA1、レガシー)と X-Hub-Signature-256(SHA256)の両方を送ります。必ず 256 のほうを使ってください。
形式:sha256=3b8a92b...
検証:
function verifyGitHub(body: string, header: string, secret: string): boolean {
const expected = "sha256=" + crypto
.createHmac("sha256", secret)
.update(body)
.digest("hex");
return safeEqual(expected, header);
}
Stripe より単純で、タイムスタンプもリプレイ攻撃の防止もありません。GitHub は、シークレットが秘密のまま保たれることに頼っています。
Shopify
Shopify は X-Shopify-Hmac-SHA256 として、生のボディの HMAC-SHA256 を hex ではなく base64 で送ります。
function verifyShopify(body: string, header: string, secret: string): boolean {
const expected = crypto
.createHmac("sha256", secret)
.update(body)
.digest("base64");
return safeEqual(expected, header);
}
罠は Stripe と同じです。ハッシュの前にボディを JSON シリアライザーに通すと壊れます。生のバイト列をハッシュしてください。
リトライとタイムスタンプの問題
Stripe のタイムスタンプの許容範囲は 5 分です。もし Stripe が最初の試行から 10 分後に元のヘッダーのままイベントをリトライしたら、そのタイムスタンプは、リトライの仕組み自身の基準で古すぎることになります。
実際には、Stripe はリトライのたびに新しい t で署名を作り直すので、問題なく動きます。しかし、キャッシュして再送するシステムやキューの後ろにあるシステムでは、古いタイムスタンプのままイベントを再送してしまうことがあります。対処は、再送する境界で、自分の署名キーを使って新しいタイムスタンプで再署名することです。
AnyHook がしているのは、まさにこれです。元のプロバイダーのヘッダーはそのまま転送されるので、既存の検証コードは最初の試行では引き続き動きます(リトライや再送では、プロバイダーの元のタイムスタンプが入ったままです)。加えて AnyHook は、送信するすべての配信に独自の AnyHook-Signature ヘッダーで署名します。このヘッダーは HMAC-SHA256 を hex エンコードしたもので、Stripe と互換の t={ts},v1={hex} という形をしています。リトライのたびに新しいタイムスタンプで再署名されるので、長いリトライが続く間も 5 分の許容範囲が正しく機能します。また、転送先ごとの署名シークレットを鍵にしているので、あるエンドポイントのシークレットが漏れても、ほかのエンドポイントには影響しません。
function verifyAnyHook(body: string, header: string, secret: string): boolean {
const t = header.match(/t=(\d+)/)?.[1];
const v1 = header.match(/v1=([a-f0-9]+)/)?.[1];
if (!t || !v1) return false;
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${body}`)
.digest("hex");
return safeEqual(expected, v1);
}
検証関数は 3 つではなく 1 つになり、リトライ時のタイムスタンプの奇妙な問題もなくなります。元のリクエストには引き続きプロバイダーが署名し、AnyHook がそれをエッジで検証してから、配信ごとに新しい独自の署名で再署名します。
チェックリスト:押さえておくべきこと
- 再シリアライズした JSON ではなく、生のボディのバイト列で検証する
-
===ではなく、定数時間比較(crypto.timingSafeEqual)を使う - 署名ヘッダーのないリクエストは拒否し、既定値を「許可」にしない
- タイムスタンプの許容範囲:5 分が標準。短いほど安全だが、正当なリトライが壊れる
- 署名シークレットは、開発者のノート PC の環境変数ではなく、暗号化した状態で保存する
- 署名シークレットを定期的にローテーションし、切り替え期間(新旧両方のキーが 24 時間有効)にも対応する
サーバーレスで最も痛い目を見るのは、生のボディの項目です。コードが動く前に、フレームワークがストリームを読み切ってしまうからです。「Vercel で Webhook を取りこぼさずに受信する」に、App Router 版と Pages Router 版を、それを黙って壊すボディパーサーも含めて載せています。
まとめ
ほとんどのプロバイダーは、生のボディに対する HMAC-SHA256 を使います。Twilio、Intercom、Vercel は HMAC-SHA1 を、Discord、SendGrid、PayPal は公開鍵署名を使います。HMAC を使うもの同士の違いは、エンコード(hex か base64 か)と、タイムスタンプを含むかどうかです。AnyHook が実装した方式はすべて、Webhook プロバイダー別リファレンスに表でまとめています。検証するヘッダーを 3 つではなく 1 つにしたいなら、プロバイダーの送信先を中継サービスに向けてください。AnyHook が複数の送信元からの受信を 1 か所にまとめ、送信する配信ごとに、一貫した 1 つの方式で再署名します。