zudo-cloudflare-wisdom

Type to search...

to open search from anywhere

HMAC-SHA256 によるウェブフック署名

作成2026年5月28日Takeshi Takatsudo

node:crypto を使わず、Web Crypto で Workers から送信するウェブフックに署名する

概要

Worker からウェブフックを送信するとき、受信側にはそのリクエストが本当に自分から 来たものであり、転送中に改ざんされていないことを確認する手段が必要です。標準的な アプローチは、共有シークレットと HMAC 署名を使う方法です。送信側で HMAC-SHA256(body, secret) を計算し、16 進ダイジェストをヘッダーに入れて送ります。 受信側は受け取ったボディに対して同じ HMAC を再計算し、比較します。

Cloudflare Workers には node:crypto がありません。ここで使うものはすべて Web Crypto API(crypto.subtle)であり、Workers ランタイムでデフォルトで利用できます。

署名スキーム

このパターンは本番の通知 Worker から採ったもので、次の構成です。

  • ヘッダー: X-Notify-Signature: sha256=<hex>
  • ボディ: 生の UTF-8 JSON ペイロードのバイト列
  • キー: 受信者ごとの webhook_secret(UTF-8 バイト列)
  • アルゴリズム: crypto.subtle.sign による HMAC-SHA256

sha256= というプレフィックスは広く使われている慣習です(GitHub のウェブフックも 同じ形式を使います)。アルゴリズム名をその場に埋め込むことでダイジェストの形式が 自己記述的になり、将来別のスキームへ移行する余地も残せます。

署名の計算

/**
 * Compute HMAC-SHA256 over `body` using `secret`, returned as lowercase hex.
 * Uses Web Crypto subtle.sign — no Node.js dependencies.
 */
export async function computeHmacHex(body: string, secret: string): Promise<string> {
  const keyBytes = new TextEncoder().encode(secret);
  const bodyBytes = new TextEncoder().encode(body);

  const cryptoKey = await crypto.subtle.importKey(
    "raw",
    keyBytes,
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"],
  );

  const sigBuffer = await crypto.subtle.sign("HMAC", cryptoKey, bodyBytes);
  const sigBytes = new Uint8Array(sigBuffer);

  return Array.from(sigBytes)
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");
}

押さえておきたい点がいくつかあります。

  • importKey("raw", …) はシークレットを生のキー材料としてインポートします。 アルゴリズム記述子 { name: "HMAC", hash: "SHA-256" } で SHA-256 を使う HMAC を 選択します。
  • false 引数はキーを抽出不可(non-extractable)に指定し、["sign"] は用途を 署名のみに限定します。
  • crypto.subtle.signArrayBuffer を返します。各バイトを padStart(2, "0") で ゼロ埋めした 2 文字の 16 進文字列へ変換すると、小文字の 16 進ダイジェストが 得られます。

ウェブフックの送信

export interface WebhookPayload {
  id: string;
  sourcePath: string;
  sourceKind: string;
  sourceItemId: string;
  payloadSummary: string;
  firedAt: string; // ISO 8601
  channels: string[];
}

/**
 * POST to the webhook URL with HMAC signature header.
 * Throws on non-2xx or network error.
 * Caller is responsible for applying timeouts.
 */
export async function sendWebhook(
  webhookUrl: string,
  webhookSecret: string,
  payload: WebhookPayload,
): Promise<void> {
  const body = JSON.stringify(payload);
  const hmacHex = await computeHmacHex(body, webhookSecret);

  const res = await fetch(webhookUrl, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-Notify-Signature": `sha256=${hmacHex}`,
    },
    body,
  });

  if (!res.ok) {
    throw new Error(`Webhook delivery error ${res.status}: ${webhookUrl}`);
  }
}

bodyJSON.stringify で一度だけ計算し、署名とリクエストボディの 両方 に 使っている点に注目してください。これは偶然の細部ではなく、正しさの根拠そのもの です。次節で説明します。

受信側が見るバイト列そのものに署名する

受信側は、受け取った生のリクエストボディに対して HMAC-SHA256(rawBody, secret) を再計算し、こちらのダイジェストと比較して検証 します。この比較が成功するためには、受信側が読むことになる まったく同じバイト 列 に対して署名しなければなりません。

これはボディが再シリアライズされた瞬間に崩れます。受信側が JSON をパースしてから ハッシュ計算前に再び文字列化すると、キーの順序、空白、数値の表現などのわずかな 違いが異なるバイト列を生み、結果として異なるダイジェストになります。シークレットは 正しいのに署名が無効に見える、という事態が起こります。

⚠️ 再パースしたオブジェクトではなく、生のボディをハッシュする

両側とも、リクエストボディの文字列そのものに対して HMAC を実行してください。送信側 では fetch のボディに入れた文字列そのものに署名し、受信側ではリクエストから読み 取った文字列そのものを検証します。JSON.parse してから JSON.stringify し、その 結果をハッシュすることは絶対に避けてください。シリアライズを行うのは送信側で一度 だけであり、受信側はそのバイト列を不透明なものとして扱う必要があります。

受信側での検証

受信側は同じ Web Crypto の手順を実行し、生のボディに対してダイジェストを再計算して 比較します。computeHmacHex はそのまま再利用でき、異なるのは向きだけです。

/**
 * Verify an incoming webhook request.
 * Reads the RAW body once and hashes exactly those bytes.
 */
export async function verifyWebhook(request: Request, secret: string): Promise<boolean> {
  const header = request.headers.get("X-Notify-Signature");
  if (!header?.startsWith("sha256=")) {
    return false;
  }
  const received = header.slice("sha256=".length);

  // Read the raw body string and hash exactly these bytes.
  const rawBody = await request.text();
  const expected = await computeHmacHex(rawBody, secret);

  return received.length === expected.length && received === expected;
}

💡 一定時間での比較

堅牢な実装にするなら、2 つの 16 進ダイジェストを一定時間(constant time)で比較し、 不一致がタイミングを通じて位置情報を漏らさないようにします。上記の長さチェックと 直接の文字列比較は、多くの内部サービスでは十分です。エンドポイントが公開されている 場合は一定時間比較の採用を検討してください。

関連: Workers での Web Crypto

これは、認証レシピが検証側で使うのと同じツールキットの署名方向です。 Auth with Pages Functions は、同じ 「Web Crypto、node:crypto なし」というテーマのもとでトークンの扱いを取り上げて います。どちらも Node の crypto ライブラリではなく crypto.subtle に依拠しており、 向きが逆になっているだけです。ここでは署名を生成し、あちらでは呼び出し元を検証 します。