コンテンツへスキップ
Tutorial

Webhook セキュリティ: HMAC 署名、冪等性、リプレイ保護

| 9 min read

3 つのコード パターンにより、スプーフィングされたペイロード、重複した配信、および Webhook リクエストのリプレイが阻止されます。 HMAC-SHA256 とタイムスタンプ チェックを使用した Node.js の例。

Digital encryption padlock on a circuit board
Photo by Adi Goldstein on Unsplash

Webhook ハンドラーは POST リクエストを受け入れ、JSON を解析し、ビジネス ロジックを実行します。 それは誰かがするまで機能します 偽造されたペイロードをエンドポイントに送信します。 または、プロバイダーが配信を再試行し、ハンドラーが料金を請求するまで、 顧客は二度。 または、攻撃者が正当なリクエストを記録し、6 時間後にそれを再生するまで。

この問題は、HMAC 署名検証、冪等性キー、タイムスタンプ ベースのリプレイ拒否という 3 つの保護によって解決されます。 このチュートリアルでは、動作する Node.js コードを使用してそれぞれを説明します。

Webhook 署名の仕組み

すべての主要な Webhook プロバイダー (Stripe、GitHub、Shopify、Twilio) は、送信ペイロードに HMAC-SHA256 で署名します。 の プロバイダーは、生のリクエスト本文を共有秘密と組み合わせ、ハッシュを計算し、そのハッシュをヘッダーに含めて送信します。 サーバーは同じシークレットを使用してハッシュを再計算し、比較します。 ハッシュが一致する場合、ペイロードは本物です。

署名式は次のようになります。

HMAC-SHA256(secret, timestamp + "." + raw_body) = signature

Stripe が署名を送信します Stripe-Signature。 GitHub がそれを送信します X-Hub-Signature-256。 アルゴリズムは同じです。 ヘッダー名と形式のみが異なります。

Node.js で Stripe Webhook 署名を検証する

ストライプの Stripe-Signature ヘッダーにはタイムスタンプ (t=) および 1 つ以上のバージョン付き 署名 (v1=)。 Stripe SDK を使用せずに検証する方法は次のとおりです。

const crypto = require('crypto');

function verifyStripeSignature(payload, header, secret) {
  const parts = header.split(',');
  const timestamp = parts.find(p => p.startsWith('t=')).slice(2);
  const signature = parts.find(p => p.startsWith('v1=')).slice(3);

  const signedPayload = timestamp + '.' + payload;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex');

  if (expected !== signature) {
    throw new Error('Invalid signature');
  }

  // Replay protection: reject if older than 5 minutes
  const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
  if (age > 300) {
    throw new Error('Timestamp too old');
  }

  return JSON.parse(payload);
}

主要な詳細: Stripe は、タイムスタンプと生の本文をピリオド区切り文字で連結します。 の v1 署名は HMAC-SHA256 を使用します。 最後のタイムスタンプ チェックにより、リプレイ保護が提供されます (詳細は以下で説明します)。

Node.js で GitHub Webhook 署名を検証する

GitHub の形式はよりシンプルです。 の X-Hub-Signature-256 ヘッダーに含まれる sha256= フォローしました 生の本体の 16 進数 HMAC による:

const crypto = require('crypto');

function verifyGitHubSignature(payload, signatureHeader, secret) {
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex');

  const trusted = Buffer.from(expected, 'utf8');
  const untrusted = Buffer.from(signatureHeader, 'utf8');

  if (trusted.length !== untrusted.length) {
    throw new Error('Invalid signature');
  }

  if (!crypto.timingSafeEqual(trusted, untrusted)) {
    throw new Error('Invalid signature');
  }

  return JSON.parse(payload);
}

知らせ crypto.timingSafeEqual。 標準 === 比較するとタイミング情報が漏洩します。 攻撃者は応答時間を測定して、バイトごとに正しい署名を推測できます。 常時比較 そのベクトルを排除します。

Botoi のハッシュ API を使用して HMAC 署名を計算する

スクリプト、CI パイプライン、またはサーバーレス関数から HMAC-SHA256 署名を計算または検証する必要がある場合 輸入せずに cryptoを使用します。 /v1/hash/hmac 終点:

curl -X POST https://api.botoi.com/v1/hash/hmac \\
  -H "Content-Type: application/json" \\
  -d '{
    "text": "1712345678.{\\"event\\":\\"payment_intent.succeeded\\",\\"amount\\":4999}",
    "key": "whsec_your_stripe_secret",
    "algorithm": "sha256"
  }'

応答:

{
  "success": true,
  "data": {
    "hmac": "a3f2b8c9d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0",
    "algorithm": "sha256"
  }
}

これにより、16 進数でエンコードされた HMAC が返されます。 これを Webhook プロバイダーからの署名ヘッダーと比較します。 エンドポイントは両方をサポートします sha256 そして sha512 アルゴリズム。

HMAC 検証用の Express ミドルウェアを構築する

コードを複製することなくすべての Webhook ルートが保護されるように、署名チェックを再利用可能なミドルウェアにラップします。

const crypto = require('crypto');

function webhookAuth(secret) {
  return (req, res, next) => {
    const signature = req.headers['x-webhook-signature'];
    const timestamp = req.headers['x-webhook-timestamp'];

    if (!signature || !timestamp) {
      return res.status(401).json({ error: 'Missing signature headers' });
    }

    // Step 1: Replay protection
    const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
    if (age > 300) {
      return res.status(401).json({ error: 'Request too old' });
    }

    // Step 2: HMAC verification
    const payload = JSON.stringify(req.body);
    const expected = crypto
      .createHmac('sha256', secret)
      .update(timestamp + '.' + payload)
      .digest('hex');

    const trusted = Buffer.from(expected, 'utf8');
    const untrusted = Buffer.from(signature, 'utf8');

    if (trusted.length !== untrusted.length ||
        !crypto.timingSafeEqual(trusted, untrusted)) {
      return res.status(401).json({ error: 'Invalid signature' });
    }

    next();
  };
}

// Mount on your webhook route
app.post('/webhooks/stripe', webhookAuth(process.env.STRIPE_WEBHOOK_SECRET), handler);

このミドルウェアは、ヘッダーの欠落、古いタイムスタンプ、または無効な署名を含むリクエストを、 ハンドラーが実行されます。 300 秒のウィンドウは、Stripe のデフォルトの許容範囲と一致します。

べき等性: 重複した配信をスキップする

Webhook プロバイダーは失敗した配信を再試行します。 イベントの処理後、イベントの処理前にサーバーが 500 を返した場合 200 応答を送信すると、プロバイダーは同じイベントを再度送信します。 冪等性がなければ、ハンドラーは 二重の副作用: 二重請求、二重メール、繰り返しの在庫更新。

修正: 処理された各イベント ID を保存し、以前に確認したイベントをスキップします。

インメモリべき等性 (開発)

const processedEvents = new Map(); // Use Redis in production

async function handleWebhook(req, res) {
  const eventId = req.body.id;

  // Check if already processed
  if (processedEvents.has(eventId)) {
    return res.status(200).json({ status: 'duplicate', eventId });
  }

  // Mark as processing before side effects
  processedEvents.set(eventId, Date.now());

  try {
    await processEvent(req.body);
    return res.status(200).json({ status: 'processed', eventId });
  } catch (err) {
    // Remove on failure so retries work
    processedEvents.delete(eventId);
    return res.status(500).json({ error: 'Processing failed' });
  }
}

Map 地域発展のために活動しています。 運用環境では、すべてのサーバー インスタンスが共有ストアを使用できるようにします。 処理されたイベントの同じセットを参照してください。

Redis を利用した冪等性 (本番環境)

const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);

async function handleWebhook(req, res) {
  const eventId = req.body.id;
  const lockKey = \`webhook:processed:\${eventId}\

SET NX アトミック: 1 つのプロセスのみがロックを獲得します。 72 時間の TTL (259200 秒) 古いエントリを自動で削除します。 処理が失敗した場合、ハンドラーはキーを削除して、次回の再試行が成功できるようにします。

リプレイ保護: 古いリクエストを拒否します

HMAC 検証により、ペイロードがプロバイダーからのものであることが確認されます。 ただし、有効な署名付きリクエストは、 ネットワーク攻撃者は、時間チェックを追加しない限り、永久に有効のままです。 リプレイ保護がペイロードを拒否する タイムスタンプがしきい値より古い。

function rejectStaleRequests(req, res, next) {
  const timestamp = parseInt(req.headers['x-webhook-timestamp'], 10);

  if (isNaN(timestamp)) {
    return res.status(401).json({ error: 'Missing timestamp' });
  }

  const now = Math.floor(Date.now() / 1000);
  const age = now - timestamp;

  // Reject anything older than 5 minutes or from the future
  if (age > 300 || age < -30) {
    return res.status(401).json({ error: 'Timestamp out of range' });
  }

  next();
}

30 秒の将来の許容誤差は、サーバーとプロバイダーの間のわずかなクロック ドリフトを処理します。 何でも 5 分より古いか、30 秒以上先のデータは拒否されます。

3 つすべてを 1 つのミドルウェアに結合

これは、タイムスタンプ、HMAC、冪等性の 3 つのチェックをすべて順番に実行する完全なミドルウェアです。

const crypto = require('crypto');
const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);

function secureWebhook(secret) {
  return async (req, res, next) => {
    const signature = req.headers['x-webhook-signature'];
    const timestamp = req.headers['x-webhook-timestamp'];
    const eventId = req.body?.id;

    // 1. Reject missing headers
    if (!signature || !timestamp || !eventId) {
      return res.status(401).json({ error: 'Missing required headers or event ID' });
    }

    // 2. Replay protection: reject stale requests
    const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
    if (age > 300 || age < -30) {
      return res.status(401).json({ error: 'Timestamp out of range' });
    }

    // 3. HMAC verification
    const payload = JSON.stringify(req.body);
    const expected = crypto
      .createHmac('sha256', secret)
      .update(timestamp + '.' + payload)
      .digest('hex');

    const trusted = Buffer.from(expected, 'utf8');
    const untrusted = Buffer.from(signature, 'utf8');

    if (trusted.length !== untrusted.length ||
        !crypto.timingSafeEqual(trusted, untrusted)) {
      return res.status(401).json({ error: 'Invalid signature' });
    }

    // 4. Idempotency: skip duplicates
    const lockKey = \`webhook:processed:\${eventId}\

順序が重要です。 タイムスタンプは最も安価なチェック (I/O なし) であるため、最初に実行されます。 HMAC 検証は CPU に依存しますが、それでも高速です。 冪等性には Redis 呼び出しが必要なため、最後に実行されます。 このシーケンスは拒否します 最も少ないコストで最も多くのリクエストを実現します。

Botoi の Webhook 受信箱を使用して Webhook ハンドラーをテストする

ボタンを使用する /v1/webhook/inbox 使い捨て URL を作成するには、次のように HMAC 署名を計算します。 /v1/hash/hmac、署名されたテスト ペイロードを送信します。 トンネルもローカルサーバーもありません。

# 1. Create a webhook inbox
INBOX=\$(curl -s -X POST https://api.botoi.com/v1/webhook/inbox/create)
INBOX_ID=\$(echo \$INBOX | jq -r '.data.inbox_id')
INBOX_URL=\$(echo \$INBOX | jq -r '.data.url')
echo "Inbox URL: \$INBOX_URL"

# 2. Compute an HMAC signature for your test payload
PAYLOAD='{"id":"evt_001","event":"order.created","amount":2999}'
TIMESTAMP=\$(date +%s)
SIGNED_PAYLOAD="\$TIMESTAMP.\$PAYLOAD"

HMAC=\$(curl -s -X POST https://api.botoi.com/v1/hash/hmac \\
  -H "Content-Type: application/json" \\
  -d "{
    \\"text\\": \\"\$SIGNED_PAYLOAD\\",
    \\"key\\": \\"test_secret_key\\",
    \\"algorithm\\": \\"sha256\\"
  }" | jq -r '.data.hmac')
echo "HMAC: \$HMAC"

# 3. Send the signed payload to your inbox
curl -s -X POST "\$INBOX_URL" \\
  -H "Content-Type: application/json" \\
  -H "X-Webhook-Signature: \$HMAC" \\
  -H "X-Webhook-Timestamp: \$TIMESTAMP" \\
  -d "\$PAYLOAD"

# 4. Retrieve and inspect the payload
curl -s -X POST "https://api.botoi.com/v1/webhook/inbox/\$INBOX_ID/list" | jq '.data.payloads'

リスト エンドポイントは、受信したすべてのペイロードを返します。

[
  {
    "id": "p_abc123",
    "received_at": "2026-04-05T14:30:00Z",
    "body": {
      "id": "evt_001",
      "event": "order.created",
      "amount": 2999
    }
  }
]

このワークフローを使用すると、コマンド ラインから完全な署名フローをテストできます。 受信トレイの URL を CI で統合テストを実行するための運用エンドポイント。

Stripe、GitHub、Shopify が Webhook に署名する方法

プロバイダー ヘッダ アルゴリズム 署名付きペイロード形式 タイムスタンプが含まれています
ストライプ Stripe-Signature HMAC-SHA256 t=UNIX.v1=HEX はい
GitHub X-Hub-Signature-256 HMAC-SHA256 sha256=HEX いいえ
ショッピファイ X-Shopify-Hmac-Sha256 HMAC-SHA256 Base64 エンコード いいえ
トゥイリオ X-Twilio-Signature HMAC-SHA1 URL + ソートされたパラメータ いいえ

Stripe は、署名にタイムスタンプをバンドルしている唯一のサービスで、すぐにリプレイ保護を提供します。 GitHub と Shopify の場合は、独自のタイムスタンプ ヘッダーを追加するか、ペイロード本文からイベントの作成時間を確認します。

本番稼働前のチェックリスト

  • Webhook シークレットは、ソース コードではなく環境変数に保存します。
  • JSON で解析されたバージョンではなく、生のリクエスト本文を解析します。 HMAC は、回線上の正確なバイトに署名します。
  • 使用 crypto.timingSafeEqual 署名の比較用。 絶対に使用しないでください ===
  • 遅い処理を実行する前に 200 を返します。 作業をキューに入れ、配信を確認します。
  • デバッグとアラートのために、拒否されたリクエスト (無効な署名、古いタイムスタンプ、重複) をログに記録します。
  • べき等ストアに TTL を設定します。 72 時間は、ほとんどのプロバイダーの再試行ウィンドウに対応します。
  • ライブプロバイダーに接続する前に、使い捨ての受信トレイと計算された HMAC 署名を使用してテストします。

FAQ

クエリ パラメーターで共有シークレットの代わりに HMAC-SHA256 を使用するのはなぜですか?
クエリ パラメーターは、プロキシとアクセス ログを介して平文で送信されます。 HMAC-SHA256 はリクエスト本文全体にシークレットを使用して署名するため、URL を傍受した攻撃者が有効な署名を偽造することはできません。
冪等性のために処理されたイベント ID をどのくらいの期間保持する必要がありますか?
少なくとも 24 ~ 72 時間保管してください。 ほとんどの Webhook プロバイダーは、そのウィンドウ内で再試行します。 72 時間後、古い ID をストアから安全に削除できます。
リプレイ保護にはどのようなタイムスタンプ許容値を使用する必要がありますか?
5分(300秒)が標準です。 Stripe は 300 秒を使用します。 ウィンドウが短いと、ネットワークの混雑により遅れて正規の配送が拒否される危険があります。
botoi ハッシュ/hmac エンドポイントを使用して受信 Webhook を確認できますか?
はい。 アルゴリズム sha256 を使用して、ペイロード本体とシークレットを /v1/hash/hmac に POST します。 返された HMAC を Webhook プロバイダーからの署名ヘッダーと比較します。
3 つの保護すべてが必要ですか、それとも 1 つを選択できますか?
HMAC 検証は最小限です。 Webhook ハンドラーが料金や電子メールなどの副作用を引き起こす場合は、冪等性を追加します。 システムが金融取引やセキュリティに敏感なイベントを処理する場合は、リプレイ保護を追加します。

botoiで開発を始めよう

150以上のAPIエンドポイント。検索、テキスト処理、画像生成、開発者ユーティリティに対応。無料プラン、クレジットカード不要。