← 所有文章
教學

Webhook HMAC 重播保護:時間戳、重試同冪等性

Webhook HMAC 重播保護需要兩道獨立控制。第一,針對完整時間戳同 raw request body 計算並驗證 HMAC-SHA256,超出自訂時效窗口嘅 delivery 一律拒絕。第二,利用穩定嘅 event ID 去除重複,因為通過驗證嘅 delivery 仍然可能重試。簽名驗證只證明內容完整,而且發送方知道共用密鑰;佢唔會令 delivery 自動變成 exactly-once。

Webhook HMAC 重播保護點樣運作

安全嘅 receiver 應該依次回答四個問題:

  1. 簽名 headers 有冇齊全,而且格式正確? 如果 endpoint 已經設定簽名,就要拒絕缺少 timestamp、signature 或 event ID 嘅請求。
  2. 請求係咪足夠新? 解析 RFC 3339 timestamp,再套用你按基礎設施制定嘅時效窗口。
  3. 完整 bytes 係咪同簽名一致? 針對 <timestamp>.<raw body> 計算 HMAC-SHA256,並以固定時間方式比較 digest。
  4. 呢個 event 之前係咪已經接受? 確認 delivery 前,先用 unique constraint 儲存穩定嘅 event ID。

最後一項特別重要,因為 retry 同 replay 並唔係同一件事。Retry 係連線失敗或收到非 2xx response 之後嘅合法重新 delivery;replay 就係喺原定處理流程以外,再次使用一個之前有效嘅已簽名請求。Timestamp freshness 限制截取到嘅請求可以被接受幾耐,而持久化 idempotency 就防止有效重試重複建立同一張工單、回覆或者 workflow。

重點一覽

  • 必須喺解析 JSON 或重新序列化之前驗證原始 request bytes。
  • 將 timestamp freshness 當成應用程式政策;UnifyPort 唔會為所有部署指定同一個固定 tolerance。
  • 只對相同長度嘅 digest buffer 使用固定時間比較。
  • 要對 X-Device-Event-Id 去重,因為 UnifyPort delivery 採用 at-least-once 模式。
  • 只有 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 嘅定義:持有同一密鑰嘅雙方可以檢查訊息完整性,同時驗證發送方知道該密鑰。HMAC 本身唔會加密 body、唔會單獨建立 freshness,亦唔保證只 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 要求 input 長度相同,所以要先驗證 hexadecimal 格式同 byte length。

令 acknowledgement path 可以安全重試

UnifyPort 會將任何 2xx response 視為 acknowledgement,並忽略 response body。連線錯誤以及 HTTP 408、429、5xx response 會按照 endpoint 設定嘅 retry_policy.max_attempts 重試,預設最多三次。其他 4xx response 唔會重試,event 會進入 dead-letter 流程。

根據呢個行為,receiver 可以採用以下設計:

Receiver 結果Response原因
Signature 缺失、過期或無效401請求唔應該進入可信 queue。
已驗證 event 已經儲存200可以安全確認重試,唔需要重複執行工作。
已驗證 event 已持久化寫入202接受之後,worker 可以繼續非同步處理。
持久化 inbox 暫時無法使用503Event 未被儲存時,等候重試比錯誤確認接收更安全。

請喺 X-Device-Event-Id 建立 unique index,唔好只用 process-local Set。Local cache 會喺 restart 後消失,亦無法協調多個 receiver instance。下游操作都要保持 idempotent:queue consumer 可能已經寫入 CRM 或發送回覆,但喺記錄完成狀態之前 crash。

Delivery order 並無保證。處理會改變狀態嘅工作時,應該按 event payload 嘅 occurred_at 排序,再用 event ID 作為 tiebreaker,唔好假設 HTTP 到達次序就係實際次序。Read receipt 有可能早過佢引用嘅 message 到達 endpoint,呢種情況尤其需要留意。

UnifyPort 適合放喺邊一層

UnifyPort 會為所有支援渠道發送同一套標準 event envelope,包括 idtypeprovideraccount_idoccurred_at 同 event-specific data。所以以上 receiver 只需要保護一條 ingress path,而唔係維護六套 channel-specific handler。註冊 endpoint、啟用 signing_secret、訂閱所需 events,再喺按 providertype routing 之前,套用同一套 timestamp、signature 同 idempotency 檢查。

最重要嘅邊界係 storage:UnifyPort 唔會保留 message history 畀你之後 backfill。Webhook events 就係流量記錄,因此 receiver 應該喺回傳 2xx 之前持久化接受 event。Signature verification 保護交接過程,而 inbox table 或 queue 就負責保存記錄。

限制同取捨

  • HMAC 可以驗證身份同保護完整性,但唔會加密 JSON body。必須繼續使用 HTTPS,並另外保護 logs 同 queues。
  • 有效 HMAC 唔代表唔會重複處理。Timestamp freshness 同 idempotency key 仍然不可缺少。
  • UnifyPort 唔會公布一個通用 timestamp tolerance。較短窗口可以限制重用,但對 clock drift 或延遲 delivery 嘅容忍度亦較低。
  • 如果 endpoint 停用簽名,X-Device-Signature 就唔會出現。要求身份驗證嘅 production receiver 應該喺 header 缺失時 fail closed。
  • 官方 provider webhook 可能採用唔同 headers、encoding 或 canonical string。每個來源都要跟返 provider 自己嘅規則,唔好將 UnifyPort string format 套用到所有 webhook。

常見問題

點解我嘅 webhook HMAC 簽名唔一致?

最常見原因係驗證已解析或重新序列化嘅 JSON,而唔係完整 raw bytes。另外要檢查 RFC 3339 timestamp、句點分隔符、正確嘅 signing_secret、hexadecimal decoding,以及 middleware 係咪喺驗證前已經消耗 body。

單靠 HMAC 可唔可以防止 webhook 重播?

唔可以。HMAC 只證明已簽名 bytes 同共用密鑰一致。你仍然要為 X-Device-Timestamp 加 freshness check,並針對 X-Device-Event-Id 做持久化去重,先可以限制重用同重複處理。

重複 event 應唔應該回傳錯誤?

唔應該。如果同一個 event ID 已經持久化接受,就回傳 2xx。回傳錯誤只會觸發另一次合法重試,唔會令結果更正確。

應唔應該處理 event 之前先確認接收?

應該喺持久化接受之後、耗時下游工作之前確認接收。先將 event 寫入 database-backed inbox 或 durable queue,回傳 2xx,再由 worker 以 idempotent 方式處理 CRM 寫入、AI processing 或回覆。

應該使用幾長嘅 freshness window?

按時鐘同步、實際 delivery latency、事故處理同風險模型選擇並記錄窗口。UnifyPort 簽名規則要求拒絕同本機時鐘相差太遠嘅 timestamp,但無指定一個固定數值。

下一步

按照 Webhook delivery 同簽名驗證指南實作完整 header 同 retry contract。診斷 mismatch 時,如果要快速逐 byte 對照,可以使用 HMAC Signature Generator作為唯一輔助工具。

來源

官方來源已於 2026 年 7 月 17 日核對: