← 全記事
チュートリアル

Webhook HMACのリプレイ対策:タイムスタンプ、再試行、冪等性

Webhook HMACのリプレイ対策には、独立した2つの制御が必要です。まず、正確なタイムスタンプとraw request bodyを連結した値でHMAC-SHA256を検証し、自分たちで定めたfreshness windowを外れたdeliveryを拒否します。次に、正当なdeliveryでも再試行される可能性があるため、安定したevent IDで重複排除します。署名検証で証明できるのは、データの完全性と送信者が共有secretを知っていることです。deliveryがexactly-onceになるわけではありません。

Webhook HMACのリプレイ対策の仕組み

安全なreceiverは、次の4つの質問に順番に答えます。

  1. 署名headersが存在し、正しい形式になっているか。 Endpointで署名を設定している場合は、timestamp、signature、event IDのいずれかが欠けたリクエストを拒否します。
  2. リクエストは十分に新しいか。 RFC 3339 timestampを解析し、自社インフラ向けに定めたfreshness windowを適用します。
  3. 正確なbytesが署名と一致するか。 <timestamp>.<raw body>に対するHMAC-SHA256を計算し、digestを一定時間で比較します。
  4. このeventはすでに受理されていないか。 Deliveryをacknowledgeする前に、安定したevent IDをunique constraint付きで保存します。

最後の確認が必要なのは、retryとreplayが同じものではないからです。Retryは接続障害や非2xx responseの後に行われる正当な再deliveryです。一方、replayは、以前は有効だった署名済みリクエストを意図した処理経路の外で再利用することです。Timestamp freshnessは取得済みリクエストを受け入れられる時間を制限し、永続的なidempotencyは正当な再試行が同じチケット、返信、workflowを二重作成するのを防ぎます。

要点

  • JSONを解析または再シリアライズする前に、raw request bytesを検証する。
  • Timestamp freshnessはアプリケーション側のポリシーとして扱う。UnifyPortは、すべてのデプロイに共通する固定toleranceを指定していない。
  • 同じ長さのdigest bufferを一定時間で比較する。
  • UnifyPortのdeliveryはat-least-onceであるため、X-Device-Event-Idで重複排除する。
  • すべての下流処理が終わった後ではなく、eventを永続的に受理した時点で2xxを返す。

UnifyPortの正確な署名仕様

Webhook endpointにsigning_secretを設定すると、UnifyPortはhexadecimal形式のX-Device-Signatureを送信します。署名対象は次の値です。

<X-Device-Timestamp>.<raw request body>

X-Device-TimestampはRFC 3339形式のUTC値であり、Unix integerではありません。同じeventを再試行する間、X-Device-Event-Idは変わりません。X-Device-Delivery-Idは、個々のdelivery attemptを識別します。signing_secretを省略するか空にすると署名は無効になり、signature headerも送信されません。

この仕様は、RFC 2104で定義されるHMACの目的に沿っています。同じsecretを共有する2者は、メッセージの完全性を確認し、そのsecretを知る送信者を認証できます。ただしHMACはbodyを暗号化せず、それ自体ではfreshnessを確立せず、1回限りのdeliveryも保証しません。これらの保証は、HTTPS、timestamp policy、HMAC検証を囲むidempotent storageによって実現します。

完全なinbound workflowを構築する場合は、n8n WhatsApp webhookチュートリアルで署名済みeventがautomationへ入る流れを確認できます。TikTok live-DM queueチュートリアルでは、検証済みの同じenvelopeをrouting前に保存すべき理由を説明しています。

Node.jsでtimestampとraw bodyを検証する

次のreceiverはbodyをBufferのまま保持し、デプロイ設定からfreshness toleranceを読み込み、Node.jsのcrypto.timingSafeEqualでbinary digestを比較して、検証済みeventを永続的なinboxへ渡します。durableInbox.insertIfAbsentは、event IDのunique keyで保護されたdatabase insertを表しています。実装時は、サービスですでに利用しているdatastoreを使用してください。

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

const app = express();
const secret = process.env.WEBHOOK_SIGNING_SECRET;
const maxAgeMs = Number(process.env.WEBHOOK_MAX_AGE_MS);

if (!secret || !Number.isFinite(maxAgeMs) || maxAgeMs <= 0) {
  throw new Error('Configure WEBHOOK_SIGNING_SECRET and WEBHOOK_MAX_AGE_MS');
}

app.post(
  '/webhooks/unifyport',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const timestamp = req.get('X-Device-Timestamp') ?? '';
    const signature = req.get('X-Device-Signature') ?? '';
    const eventId = req.get('X-Device-Event-Id') ?? '';

    if (!timestamp || !signature || !eventId) {
      return res.sendStatus(401);
    }

    const signedAtMs = Date.parse(timestamp);
    const ageMs = Math.abs(Date.now() - signedAtMs);
    if (!Number.isFinite(signedAtMs) || ageMs > maxAgeMs) {
      return res.sendStatus(401);
    }

    const expected = crypto
      .createHmac('sha256', secret)
      .update(timestamp + '.')
      .update(req.body)
      .digest();

    const validHex = /^[0-9a-f]{64}$/i.test(signature);
    const provided = validHex ? Buffer.from(signature, 'hex') : Buffer.alloc(0);
    const validSignature =
      provided.length === expected.length &&
      crypto.timingSafeEqual(provided, expected);

    if (!validSignature) {
      return res.sendStatus(401);
    }

    const event = JSON.parse(req.body.toString('utf8'));
    const accepted = await durableInbox.insertIfAbsent({
      id: eventId,
      occurredAt: event.occurred_at,
      payload: event,
    });

    return res.sendStatus(accepted ? 202 : 200);
  },
);

Freshness checkはHMAC比較より前にありますが、timestampが信頼されるのは両方の検証に通過した後だけです。このreceiverは、明らかに古いinputを早い段階で拒否しているだけです。WEBHOOK_MAX_AGE_MSには、時刻同期、通常のdelivery latency、インシデント対応手順、リスクモデルを反映してください。無関係なproviderのtoleranceをそのままコピーし、自社のqueueにも適合すると判断しないでください。

Node.jsのドキュメントでは、crypto.timingSafeEqualはHMAC digestの比較に適している一方、周辺コードでtiming leakを生じさせないよう注意を促しています。timingSafeEqualは同じ長さの入力を必要とするため、先にhexadecimal形式とbyte lengthを検証します。

Acknowledgement pathを再試行に強くする

UnifyPortはすべての2xx responseをacknowledgementとして扱い、response bodyを破棄します。接続エラーとHTTP 408、429、5xx responseは、endpointに設定されたretry_policy.max_attemptsまで再試行されます。デフォルトは3回です。それ以外の4xx responseは再試行されず、eventはdead-letter処理されます。

この動作を前提にすると、receiverは次のように設計できます。

Receiverの結果Response理由
Signatureがない、古い、または無効401信頼済みqueueへ入れるべきリクエストではない。
検証済みeventが保存済み200処理を繰り返さず、安全に再試行をacknowledgeできる。
検証済みeventを永続的にinsert済み202受理後はworkerが非同期で処理を続けられる。
永続的なinboxを一時的に利用できない503保存されていないeventをacknowledgeするより、再試行させる方が安全。

Process-localなSetではなく、X-Device-Event-Idにunique indexを設定してください。Local cacheはrestartすると消え、複数のreceiver instance間で状態を共有できません。下流の操作もidempotentにします。Queue consumerは、CRMへの書き込みや返信の送信を終えた後、完了状態を記録する前にcrashする可能性があります。

Delivery orderは保証されません。状態を変更する処理は、HTTPの到着順を前提にせず、event payloadのoccurred_atで並べ、event IDをtiebreakerとして使います。参照先messageより先にread receiptがendpointへ届く場合は、特に重要です。

UnifyPortが担う範囲

UnifyPortは、idtypeprovideraccount_idoccurred_at、event固有のdataを含む同じ標準event envelopeを、対応するすべてのチャネルでdeliveryします。そのため、上記receiverは6つのchannel-specific handlerではなく、1つのingress pathを保護できます。Endpointを一度登録し、signing_secretを有効にして、必要なeventsをsubscribeします。その後、providerまたはtypeでroutingする前に、同じtimestamp、signature、idempotencyの検証を適用します。

重要な境界はstorageです。UnifyPortは、後からbackfillするためのmessage historyを保存しません。Webhook eventsが通信記録になるため、receiverは2xxを返す前にeventを永続的に受理する必要があります。Signature verificationが受け渡しを保護し、inbox tableまたはqueueが記録を保持します。

制限とトレードオフ

  • HMACは送信者を認証し、完全性を保護しますが、JSON bodyを暗号化しません。HTTPSを有効にし、logsとqueuesも個別に保護してください。
  • 有効なHMACでも重複処理は防げません。Timestamp freshnessとidempotency keyが引き続き必要です。
  • UnifyPortは、すべてに共通するtimestamp toleranceを公開していません。短いwindowは再利用を制限できますが、clock driftや遅延deliveryに対する許容度も下がります。
  • Endpointで署名を無効にすると、X-Device-Signatureは送信されません。認証を必須とするproduction receiverは、headerがない場合にfail closedにしてください。
  • 公式provider webhookでは、異なるheaders、encoding、canonical stringを使う場合があります。すべてのwebhook sourceにUnifyPortのstring formatを適用せず、それぞれのprovider仕様に従ってください。

よくある質問

Webhook HMAC署名が一致しないのはなぜですか?

最も多い原因は、正確なraw bytesではなく、解析または再シリアライズしたJSONを検証していることです。RFC 3339 timestamp、リテラルのドット区切り、正しいsigning_secret、hexadecimal decodingも確認してください。Middlewareが検証前にbodyを消費していないかも確認が必要です。

HMACだけでWebhookのリプレイを防げますか?

防げません。HMACが証明するのは、署名済みbytesが共有secretと一致することです。X-Device-Timestampのfreshness checkと、X-Device-Event-Idの永続的な重複排除を追加して、再利用と重複処理を制限してください。

重複eventにはエラーを返すべきですか?

いいえ。同じevent IDをすでに永続的に受理している場合は、2xxを返します。エラーを返しても、別の正当な再試行を招くだけで、正確性は向上しません。

Eventを処理する前にacknowledgeすべきですか?

永続的に受理した後、時間のかかる下流処理を始める前にacknowledgeします。Eventをdatabase-backed inboxまたはdurable queueへinsertし、2xxを返してから、workerがCRMへの書き込み、AI processing、返信をidempotentに処理します。

どの程度のfreshness windowを使うべきですか?

同期された時刻、観測したdelivery latency、インシデント対応、リスクモデルに基づいてwindowを選び、文書化してください。UnifyPortの署名仕様では、自分の時計から離れすぎたtimestampを拒否する必要がありますが、固定値は指定していません。

次のステップ

Webhook deliveryと署名検証ガイドに沿って、正確なheaderとretry contractを実装してください。Mismatchを調査しながらbyte単位ですぐに比較する場合は、補助ツールとしてHMAC Signature Generatorを利用できます。

出典

公式情報は2026年7月17日に確認しました。