12k
All articles

Webhook署名を検証する方法

ExpressでStripeとGitHubのWebhook署名を検証。生のリクエスト本文、一定時間のHMAC比較、リプレイ対策、シークレットのローテーションを解説します。

OpenReplay Team
OpenReplay Team
Webhook署名を検証する方法

Webhook署名を検証するには、共有シークレットを使ってリクエストの生のボディ(raw body)からHMAC-SHA256ダイジェストを計算し、送信元がリクエストヘッダーに付与した署名と定数時間で比較します。両者が一致しなければ、リクエストを拒否します。

ハンドラーはすでに何週間も本番環境で稼働しています。ところが署名チェックを追加した途端、シークレットは間違いなく正しいはずなのに、すべての配信が「invalid signature」で失敗するようになります。

Webhookエンドポイントは公開URLであり、誰でもPOSTできます。署名チェックがなければ、ハンドラーは届いたものを何でも信用してしまいます。このパターンを先におさらいしたい場合は、OpenReplayのWebhookガイドで基本を解説しています。本記事では、StripeとGitHub向けに正しく動作するExpressハンドラーを構築します。raw bodyミドルウェアの順序、定数時間比較、リプレイ対策、2つのシークレットを使ったローテーション対応を取り上げ、各ステップが必要な理由を説明します。

重要ポイント

  • Webhook署名は、リクエストのバイト列そのものに対するHMAC-SHA256ダイジェストです。そのため、検証前にJSONをパースして再シリアライズすると、検証は失敗します。
  • Expressでは、グローバルなapp.use(express.json())よりも前に、express.raw({ type: 'application/json' })を指定してWebhookルートを登録します。最初に実行されたボディパーサーがリクエストストリームを消費するためです。
  • crypto.timingSafeEqualは、2つのバッファの長さが異なると例外をスローします。先に長さを比較し、一致しない場合は無効な署名として扱ってください。
  • Stripeは${t}.${rawBody}に署名し、有効なシークレットごとにv1署名を1つずつ送信することがあります。GitHubは生のボディのみに署名し、タイムスタンプを含まないため、配信IDで重複排除を行います。

Webhook署名検証の仕組み

Webhook署名とは、あなたと送信元だけが保持するシークレットを使って、送信元が生のリクエストボディから計算したHMACダイジェストです。ダイジェストが一致すれば、ペイロードが改ざんされていないこと、そしてそのシークレットを持つ者から送られたことの2点が証明されます。HMACはシークレットをSHA-256ハッシュに組み込むため、シークレットを知らなければ、特定のボディに対して有効なダイジェストを生成することは誰にもできません。受信側のサーバーは同じ計算を行い、結果を比較します。

ここで取り上げる2つのプロバイダーは同じプリミティブを使用していますが、署名対象の文字列が異なります。

StripeGitHub
ヘッダーStripe-Signature: t=<unix>,v1=<hex>X-Hub-Signature-256: sha256=<hex>
署名対象の文字列${t}.${rawBody}生のボディ
エンコーディングhexhex
タイムスタンプへの署名ありなし
リプレイ対策古いtを拒否X-GitHub-Deliveryで重複排除

Stripeは手動検証のためのヘッダー形式と署名対象ペイロードをドキュメントで公開しています。テストイベントには偽のv0署名も含まれるため、v1以外のスキームはすべて無視してください。GitHubはWebhook配信の検証でスキームを説明しており、値は必ずsha256=で始まります。GitHubは旧来のSHA-1によるX-Hub-Signatureヘッダーも引き続き送信していますが、これは古いインテグレーションを動作させ続けるためだけに残されています。

JSONパーサーより先に生のボディを読み取る

Webhook署名の検証が失敗し続ける最も一般的な原因は、検証より前にJSONパーサーが実行されていることです。HMAC検証はバイト単位で厳密に行われます。パース済みのオブジェクトを再シリアライズすると、空白、キーの順序、エスケープのどれか1つが変わるだけで、ダイジェストは一致しなくなります。

問題が起きやすいのはミドルウェアの順序です。Expressでは、グローバルなapp.use(express.json())よりも前に、express.raw()を指定してWebhookルートを登録します。JSONパーサーがリクエストストリームを消費すると、元のバイト列は失われます。

// Broken: the global JSON parser runs first, so req.body is a parsed object
app.use(express.json());
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), handler);

// Fixed: webhook routes first, global JSON parser after
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), handler);
app.use(express.json());

壊れているバージョンは、ルートにexpress.raw()を追加しても直りません。body-parserでは、最初に実行されたパーサーがストリームを読み取り、リクエストをパース済みとしてマークします。後から実行されるパーサーは、そのリクエストをスキップします。そのため、ルートレベルのrawパーサーは何も受け取れず、req.bodyはexpress.json()が生成したオブジェクトのままになります。

ハンドラーにはBuffer.isBuffer(req.body)のガードを追加しましょう。これにより、原因が分かりにくい「署名の不一致」を、明白な設定エラーとして検出できます。本記事のコードはExpress 5を対象としています。Content-Typeがパーサーと一致しない場合、Express 5ではreq.bodyがundefinedになり、Express 4では{}になっていました。このガードはどちらの場合も400を返します。

ミドルウェアの順序を変更できない場合は、express.json()のverifyオプションを使いましょう。パース前の生のBufferを取得できるので、後で使うためにreqに保存しておけます。Next.js App RouterのRoute Handlerでは、コードより前にボディパーサーが実行されることはありません。Buffer.from(await request.arrayBuffer())でバイト列を読み取って検証し、その後でJSON.parseを呼び出してください。

期待される署名を計算し、定数時間で比較する

ダイジェストはcrypto.createHmac('sha256', secret)で計算し、Bufferを直接渡します。ボディを文字列にデコードしてから再エンコードすると、バイト列が変化しうるステップが余分に増えてしまいます。Stripeの場合は.update(`${t}.`).update(rawBody)のようにチェーンし、ボディに手を加えずにハッシュへ渡します。

Webhook署名は===ではなく、定数時間で比較する必要があります。文字列比較は最初に異なる文字が見つかった時点で処理を終えることがあるため、実行時間から「推測がどこまで正しかったか」が漏洩します。crypto.timingSafeEqual(a, b)は、バッファのどの位置が異なっていても同じ時間で処理します。ただし、2つのバッファの長さが異なる場合は例外をスローします。先に長さをチェックしておけば、不正な形式のヘッダーによってハンドラーが500エラーでクラッシュすることはなく、無効な署名として扱われます。

function safeEqualHex(expectedHex, receivedHex) {
  const a = Buffer.from(expectedHex, 'hex');
  const b = Buffer.from(receivedHex, 'hex');
  if (a.length === 0 || a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

署名の検証が失敗し続ける場合は、以下を順番に確認してください。

  1. req.bodyがBufferであること(Buffer.isBuffer(req.body)をログに出力します)。
  2. シークレットが、このエンドポイントとモードに正しく対応していること。Stripeではエンドポイントごとに固有のシークレットがあり、テストモードと本番モードの両方で使うエンドポイントには、モードごとに別のシークレットがあります。
  3. GitHubからX-Hub-Signature-256ヘッダー自体が送られてこない場合、そのWebhookにはシークレットが設定されていません。GitHubのトラブルシューティングページの説明に従い、旧来のSHA-1ヘッダーではなくSHA-256ヘッダーを読み取っていることを確認してください。
  4. 署名対象の文字列が正しいこと。Stripeではt.プレフィックスが必要です。GitHubではsha256=プレフィックスを取り除く必要があります。
  5. 双方がhexであること。ログに出力した生のボディとシークレットをOpenReplay HMACジェネレーターに貼り付け、得られたダイジェストをヘッダーの値と目視で比較します。

タイムスタンプまたは配信IDでリプレイを拒否する

有効なWebhook署名が証明するのは「誰が」リクエストを送ったかであり、「いつ」送ったかではありません。そのため、一度傍受された署名付きリクエストは、後から再送(リプレイ)される恐れがあります。Stripeでは、署名済みタイムスタンプが約5分以上前の配信を拒否します。これはStripeライブラリのデフォルトの許容範囲と同じです。タイムスタンプに署名しないGitHubでは、各配信IDを記録し、重複をスキップします。

タイムスタンプより先に署名をチェックしてください。tを含むHMACが検証されるまで、tは攻撃者が自由に設定できるただのテキストにすぎません。また、数値でないtも失敗として扱ってください。NaNとの比較はfalseと評価されるため、単純な鮮度チェックを知らないうちに通過してしまいます。

GitHubのX-GitHub-DeliveryヘッダーにはイベントごとのGUIDが含まれており、再配信時も同じGUIDが使われます。IDは、処理が成功した後にのみ記録してください。GitHubは失敗した配信を自動的には再試行せず、10秒以内に2xxレスポンスを受け取れなければ、その配信を失敗とみなします。失敗した処理の前にIDを記録してしまうと、後から手動でトリガーした再配信がスキップされてしまいます。

ローテーション中は2つのシークレットを受け入れる

Webhookシークレットのローテーション中は、現在有効なシークレットのいずれかで生成したダイジェストが、ヘッダー内のいずれかのv1値と一致すれば、リクエストを受け入れます。Stripeのエンドポイントシークレットをロールすると、古いシークレットを最大24時間の範囲で、指定した期間だけ有効なまま残せます。有効期限が切れるまで、すべての配信には、有効なシークレットごとに個別のv1署名が付与されます。

ヘッダーをオブジェクトに変換するタイプのStripeヘッダーパーサーでは、v1が1つしか保持されず、有効な配信まで拒否されてしまいます。すべての値を配列に収集してください。GitHubでは、設定済みの各シークレットを1つのヘッダーに対して順に試します。some()ループは一致した時点で処理を終えますが、個々の比較は定数時間のままなので、攻撃に役立つ情報は漏洩しません。

完全なExpressハンドラー

以下のESMハンドラーは、これまでのすべてのステップを組み合わせたものです。署名や形式の不備にはすべて400を返します(送信元が区別するのは2xxかそれ以外かだけです)。GitHubの重複配信には200を返し、受信を確認したうえでスキップします。

import express from 'express';
import crypto from 'node:crypto';

const app = express();
const TOLERANCE_SECONDS = 300;

// Current secret first, previous secret during rotation
const STRIPE_SECRETS = [
  process.env.STRIPE_WEBHOOK_SECRET,
  process.env.STRIPE_WEBHOOK_SECRET_PREVIOUS,
].filter(Boolean);
const GITHUB_SECRETS = [
  process.env.GITHUB_WEBHOOK_SECRET,
  process.env.GITHUB_WEBHOOK_SECRET_PREVIOUS,
].filter(Boolean);

if (STRIPE_SECRETS.length === 0 || GITHUB_SECRETS.length === 0) {
  throw new Error('Webhook secrets are not configured');
}

function safeEqualHex(expectedHex, receivedHex) {
  const a = Buffer.from(expectedHex, 'hex');
  const b = Buffer.from(receivedHex, 'hex');
  if (a.length === 0 || a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

function parseStripeHeader(header) {
  let timestamp = null;
  const signatures = [];
  for (const part of header.split(',')) {
    const i = part.indexOf('=');
    if (i === -1) continue;
    const key = part.slice(0, i).trim();
    const value = part.slice(i + 1).trim();
    if (key === 't') timestamp = value;
    else if (key === 'v1') signatures.push(value); // keep every v1
  }
  return { timestamp, signatures };
}

app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('Stripe-Signature');
  if (!header || !Buffer.isBuffer(req.body)) {
    return res.status(400).send('Missing signature or raw body');
  }

  const { timestamp, signatures } = parseStripeHeader(header);
  if (!timestamp || signatures.length === 0) {
    return res.status(400).send('Malformed signature header');
  }

  const valid = STRIPE_SECRETS.some((secret) => {
    const expected = crypto
      .createHmac('sha256', secret)
      .update(`${timestamp}.`)
      .update(req.body)
      .digest('hex');
    return signatures.some((sig) => safeEqualHex(expected, sig));
  });
  if (!valid) return res.status(400).send('Invalid signature');

  // Only trust t after the HMAC over it has been verified
  const age = Math.floor(Date.now() / 1000) - Number(timestamp);
  if (!Number.isFinite(age) || Math.abs(age) > TOLERANCE_SECONDS) {
    return res.status(400).send('Timestamp outside tolerance');
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Hand event off to a queue; respond fast
  res.sendStatus(200);
});

const seenDeliveries = new Set(); // Use a shared store with a TTL in production

app.post('/webhooks/github', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('X-Hub-Signature-256');
  if (!header?.startsWith('sha256=') || !Buffer.isBuffer(req.body)) {
    return res.status(400).send('Missing signature or raw body');
  }
  const received = header.slice('sha256='.length);

  const valid = GITHUB_SECRETS.some((secret) =>
    safeEqualHex(
      crypto.createHmac('sha256', secret).update(req.body).digest('hex'),
      received,
    ),
  );
  if (!valid) return res.status(400).send('Invalid signature');

  const deliveryId = req.get('X-GitHub-Delivery');
  if (!deliveryId) return res.status(400).send('Missing delivery ID');
  if (seenDeliveries.has(deliveryId)) return res.sendStatus(200);

  const payload = JSON.parse(req.body.toString('utf8'));
  // Process payload, then record the ID only after success
  seenDeliveries.add(deliveryId);
  res.sendStatus(200);
});

app.use(express.json()); // Everything else, after the webhook routes

app.listen(3000);

各ガードは、それぞれ特定の失敗に対処しています。ヘッダーが欠落していても例外はスローせず、400を返します。v1値はすべて保持されます。timingSafeEqualの前に長さチェックが実行されます。Buffer以外のボディは設定エラーとして検出されます。Stripeだけを扱うアプリであれば、公式ライブラリのstripe.webhooks.constructEventが同じチェックを代わりに行ってくれます。呼び出し方はStripeのWebhookドキュメントに記載されています。

まとめ

Webhook署名の検証は、届いたバイト列をそのままハッシュ化し、その結果を安全に比較することに尽きます。ハンドラーのそれ以外の部分は、バイト列を損なわずに保ち、リプレイを阻止し、シークレットのローテーションに対応するためにあります。まずWebhookルートをexpress.json()より前に移動し、その後で上記のハンドラーを追加してください。プッシュ型の配信が自分のインテグレーションに合っているかまだ検討中であれば、Webhookとポーリングの比較でそれぞれのトレードオフを解説しています。

よくある質問

Stripe CLIを使ってローカルでテストすると、Stripeの署名検証が失敗するのはなぜですか?

Stripe CLIは独自の署名シークレットを使用します。これは、Stripeダッシュボードで作成したどのエンドポイントのシークレットとも異なります。どちらもwhsec_で始まるため、混同しやすくなっています。stripe listenを実行すると、CLIはターミナルにそのシークレットを表示します。CLIが転送するイベントにはこの値を使用してください。CLI経由で転送されたイベントをダッシュボードのエンドポイントシークレットで検証したり、その逆を行ったりしてはいけません。

StripeのconstructEventを使うべきですか、それとも署名を手動で検証すべきですか?

Stripeが唯一のプロバイダーであれば、stripe.webhooks.constructEventを使用してください。生のリクエストボディ、Stripe-Signatureヘッダーの値、エンドポイントシークレットを渡すと、署名が正しくない場合にエラーをスローします。Stripeのライブラリはデフォルトで、現在時刻から5分以上ずれた署名済みタイムスタンプを拒否します。許容範囲はオプションの引数で変更できます。手動検証が適しているのは、GitHubなど他のプロバイダーと同じコードパスを共有する場合です。

シークレットとボディをまとめてSHA-256でハッシュ化するのではなく、HMACを使うのはなぜですか?

HMACは長さ拡張攻撃(length-extension attack)に耐性があるからです。シークレットの後にボディを連結してSHA-256を計算するような単純な構成は、この攻撃で破られます。SHA-256はMerkle-Damgard構造を採用しているため、有効なダイジェストを入手した者は、シークレットを知らなくてもメッセージにデータを追加し、延長後のメッセージに対する有効なダイジェストを計算できてしまいます。HMACはキーを使って2段階の入れ子になったハッシュ処理を行うため、この攻撃を防げます。StripeとGitHubはいずれもHMAC-SHA256で署名しています。

Webhook署名を検証していても、HTTPSは必要ですか?

はい、必要です。HMAC署名が保護するのは完全性と真正性であり、機密性は保護しません。TLSがなければ、ネットワーク経路上の誰もがペイロードを読み取れます。ペイロードには顧客情報や決済データが含まれている場合もあります。さらに、署名付きリクエストを傍受して、後でリプレイすることも可能です。署名チェック、リプレイ対策、HTTPSはそれぞれ異なる脅威に対処するものなので、本番環境のWebhookエンドポイントにはこの3つすべてが必要です。

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.