エンジニアリング2026年7月30日7 分で読めます

Webhook の署名検証テストは何も証明していない

テストが検証コードと同じ式で署名を作っているなら、両方が間違っていてもテストは通ります。プロバイダーの SDK と突き合わせて確認してください。

通った署名テストが実際にカバーする範囲自作フィクスチャ → 自作の検証処理どちらも同じ前提で作られている実際のリクエストパース済みボディ · プロキシの書き換え · リトライ時刻 · キーの更新コードが動く前にストリームを読み切ってしまう層に、テストは一度も触れない何かを証明できるのは、本物の署名付き配信を実際のスタックに通すテストだけ
テストとテスト対象のコードは、たいてい同じ誤った前提を共有している。

これは、テストが通り、きちんとして見えるのに、ほとんど何も証明していない Webhook の署名テストです:

const ts  = Math.floor(Date.now() / 1000).toString();
const sig = await hmacHex(`${ts}.${body}`, secret, "SHA-256");
const headers = new Headers({ "stripe-signature": `t=${ts},v1=${sig}` });

expect(await verifySignature("stripe", headers, body, secret)).toBe(true);

フィクスチャは、検証コードがチェックするのと同じ式で作られています。検証コードを書いたときに Stripe のドキュメントを読み違えていたら(署名する文字列を間違えた、区切り文字を間違えた、base64 にすべきところを hex にした)、テストにも同じ読み違いが組み込まれ、両方ともグリーンのままです。テストしたのは「Stripe の署名を検証できる」ではなく、「自分自身と矛盾しない」です。

AnyHook には、20 のプロバイダーにわたって、まさにこういうテストが 70 個ありました。すべて通っていました。そこで、唯一重要な問いを立てました:プロバイダー自身のコードが作った署名は、AnyHook の検証コードを通るのか?

HMAC の対象は意味ではなくバイト列受信したバイト列{"id":"evt_1","n":2}req.text()hmac一致パースして再文字列化{ "n": 2, "id": "evt_1" }req.json()hmac不一致データは同じデータは同じ。キーの順序と空白が違うだけで、ログで見るペイロードは完璧に見える。
フィクスチャでは再現できない失敗:フレームワークがすでにストリームを読み終えているので、ハッシュするバイト列は、署名されたバイト列ではない。

プロバイダーの SDK と突き合わせる

公式 SDK に署名用の関数を含めているプロバイダーがあり、たいていはまさにこの目的のためです。Stripe なら generateTestHeaderString、Svix なら Webhook クラスの .sign() です。これらがすでに依存関係にあるなら、突き合わせにコストはかかりません:

import Stripe from "stripe";
import { Webhook } from "svix";
import { verifySignature } from "./index";

it("accepts a header generated by stripe-node", async () => {
  const stripe  = new Stripe("sk_test_" + "0".repeat(24));
  const payload = JSON.stringify({ id: "evt_1", type: "charge.succeeded" });
  const secret  = "whsec_" + "s".repeat(32);

  const header = stripe.webhooks.generateTestHeaderString({ payload, secret });

  expect(
    await verifySignature("stripe", new Headers({ "stripe-signature": header }), payload, secret)
  ).toBe(true);
});

it("accepts a signature generated by the svix SDK", async () => {
  const secret  = "whsec_" + Buffer.from("k".repeat(24)).toString("base64");
  const payload = JSON.stringify({ type: "email.received" });
  const msgId   = "msg_crosscheck";
  const ts      = new Date();

  const signature = new Webhook(secret).sign(msgId, ts, payload);
  const headers = new Headers({
    "svix-id": msgId,
    "svix-timestamp": Math.floor(ts.getTime() / 1000).toString(),
    "svix-signature": signature,
  });

  expect(await verifySignature("svix", headers, payload, secret)).toBe(true);
});

これで、アサーションが確かめるのは自分自身との一致ではなく、Stripe との一致になります。ネガティブケース(ペイロードに署名し、別のボディを検証する)も追加して、テストが実際に失敗しうることを確認してください:

const header   = stripe.webhooks.generateTestHeaderString({ payload, secret });
const tampered = JSON.stringify({ id: "evt_1", amount: 999999 });

expect(
  await verifySignature("stripe", new Headers({ "stripe-signature": header }), tampered, secret)
).toBe(false);

Clerk と Resend はどちらも Svix の基盤で配信しているので、Svix の突き合わせ 1 つで 3 つのプロバイダーをカバーできます。AnyHook の 20 の検証コードのうち 4 つが、いまでは SDK による裏付けを持っています。午後のうちに終わったこの作業は、どんなスタックにも応用できます。

公開された署名関数がないプロバイダーは?

Shopify、Discord、PayPal:ほとんどのプロバイダーは署名用の関数を提供していません。その場合、唯一の正直な証拠は、実際の配信をキャプチャしたものです。自分が管理するエンドポイントに Webhook を向け、署名付きのリクエストを 1 件記録し、(headers, body, secret) の 3 つ組をフィクスチャとして固定します。記録した本物の署名こそが、自作のフィクスチャには決してなれない正解データです。

それが済むまでは、テストが何を示しているのかを、自分に対して正確に把握してください。AnyHook では、テストファイルの末尾のコメントに台帳を残しています:どのプロバイダーが SDK で検証済みか、どれが自己整合的なだけか、どれにはポジティブなテストがまったくないか。行動につなげられる正直なリストは、信用できないグリーンのテストスイートに勝ります。

この考え方で同じ日に見つかったバグ

同じ疑いは、ユニットテストの外にも当てはまります。Resend の経路を本番環境でエンドツーエンドに検証していたとき(ユニットテストではなく、実際にデプロイされたもので)、署名シークレットを設定したエンドポイントに 3 件のリクエストを送りました:

リクエスト期待した結果実際の結果
正しい署名200200
間違った署名401401
署名なし401200

署名のないリクエストが、保護されているように見えたエンドポイントをすんなり通り抜けました。原因は暗号のバグではなく、ポリシーの穴でした。送信元が generic の App(Webhook の送信元ごとに作る受信設定)は、設計上、検証を省きます(「URL が欲しいだけ」という用途のため)。そして、シークレットを設定しても App は generic のままでした。シークレットは何も検証しないまま置かれ、API はその保存に成功を返していました。

自己整合的なユニットテストをいくら重ねても、これは見つかりません。どのユニットも正しかったからです。修正として、誤解を招く設定そのものを拒否するようにしました:generic の App に署名シークレットを設定すると、いまは 400 が返り、代わりにどのフィールドを設定すべきかを示します。

チェックリスト

  1. SDK に署名関数があるプロバイダーはすべて、両方向で突き合わせます(正しいものは通り、改ざんしたものは失敗する)
  2. それ以外は、署名付きの実際の配信を 1 件キャプチャし、フィクスチャとして固定します
  3. どのプロバイダーにどのレベルの裏付けがあるかを、見える形で台帳に残します
  4. デプロイ済みのシステムを、間違った署名だけでなく、署名がない状態でもテストします。抜け穴は数学ではなく、ポリシーにあります
すべての記事2026年7月30日 · 7 分

Webhook を、もう取りこぼさない。

URL を 1 つ変えるだけで、リトライ、イベントログ、1 クリックでの再送が使えます。先に動きを見ることもできます。新規登録して 30 秒の障害訓練を実行すると、サーバーがダウンしていてもイベントが失われない様子を確認できます。サーバーを用意する必要はありません。