Skip to Content
Webhook簽章驗證

簽章驗證

每次 Webhook 投遞都會帶上兩個搭配使用的標頭:

X-Signature: sha256=9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b X-Timestamp: 1729536000

sha256= 後的十六進位字串是 HMAC-SHA256(secret, timestamp + "." + raw_body)。 點號是字面位元組,timestamp 是 ASCII 形式的 Unix 秒。

為什麼要驗證

Webhook URL 會外洩。它們會出現在代理日誌、截圖、瀏覽器歷史、合作 夥伴的支援工單裡。若沒有簽章檢查,任何知道你 URL 的人都能 POST 一個 偽造的 payment.settled 事件,讓你為未付款的訂單履約。驗證從密碼學 層面證明請求真的來自 InfraIO。

把 timestamp 放進簽章酬載也能提供防重放:攻擊者即使捕獲了一次 投遞,過了你的容差視窗後簽章就會被偵測為過期。

演算法

signed_payload = timestamp + "." + raw_body expected_header = "sha256=" + lowercase_hex( HMAC_SHA256(secret, signed_payload) ) constant_time_compare(expected_header, x_signature_header)

接著檢查 timestamp 是新的(典型容差:±5 分鐘)。

永遠傳原始請求 body 位元組。框架經常在你的處理函式執行前就先 解析 JSON;重新序列化後的版本可能與我們發送的不同(鍵序、空白、 數字格式),HMAC 就會對不上。Next.js App Router 內,在 JSON.parse 之前使用 await req.text()。Express 內,僅在 Webhook 路由上 掛載 express.raw({ type: 'application/json' })

實作範例

lib/verify-infraio.ts
import { createHmac, timingSafeEqual } from "node:crypto"; const TOLERANCE_SECONDS = 5 * 60; export function verifyInfraIo({ body, signature, timestamp, secret, }: { body: string; // 原始文字 — 非解析過的 JSON signature: string; // X-Signature 標頭的值 timestamp: string; // X-Timestamp 標頭的值(Unix 秒) secret: string; // whsec_… }): boolean { const ts = Number.parseInt(timestamp, 10); if (!Number.isFinite(ts)) return false; if (Math.abs(Math.floor(Date.now() / 1000) - ts) > TOLERANCE_SECONDS) { return false; // 太舊或太靠未來 } const expected = "sha256=" + createHmac("sha256", secret) .update(`${timestamp}.${body}`) .digest("hex"); const a = Buffer.from(signature); const b = Buffer.from(expected); return a.length === b.length && timingSafeEqual(a, b); }

防重放

簽章酬載中的 timestamp 是第一道防線 — 捕獲一次投遞的攻擊者在 你的容差視窗過期後就無法再重發。

雙重保險(對 payment.settled 等高價值事件推薦):

  1. X-Delivery 去重,使用帶唯一限制的資料表。容差視窗內的 重放會變成 no-op — 你的處理函式回 200 而不會做兩次。這正是 合法重試也需要的冪等性。(X-Delivery 在一筆投遞的所有重試間保持穩定; 酬載中並沒有 event_id 欄位。)
  2. 使用時鐘漂移所允許的最小容差。 ±5 分鐘是推薦預設,符合 多數 NTP 同步機群可維持的範圍。更嚴格也可以;但低於 ±30 秒後, 在上游 NTP 較慢的網路上你會開始拒絕合法的投遞。

輪替金鑰

  1. 儀表板 → Developers → Webhooks → [endpoint] → ⋮ → Rotate Secret。
  2. 產生新金鑰並僅顯示一次。關閉對話框前請複製。
  3. 在 24 小時內更新環境變數並重新部署你的驗證器。

寬限視窗(雙簽)

輪替後的 24 小時 內,每次投遞都會帶兩個簽章:

X-Signature: sha256=<hmac(new_secret, ts + "." + body)> X-Signature-Prev: sha256=<hmac(prev_secret, ts + "." + body)> X-Timestamp: 1729536000

執行前一個金鑰的驗證器會匹配 X-Signature-Prev;執行金鑰 的驗證器會匹配 X-Signature。任一標頭通過就足夠 — 處理函式可以在 遷移期接受投遞,而不必阻塞部署。

寬限視窗關閉後只發送 X-Signature。前一個金鑰不再被接受,任何仍 設定它的驗證器都會開始拒絕投遞 — 所以請在 24 小時預算內完成發佈。

建議的接收方模式

// 在輪替寬限視窗內接受任一簽章。 const sig = req.headers["x-signature"] ?? ""; const sigPrev = req.headers["x-signature-prev"] ?? ""; const ok = verify(body, sig, ts, CURRENT_SECRET) || (PREV_SECRET && verify(body, sigPrev, ts, PREV_SECRET));

一旦你端點的寬限視窗已過期、且 env 中的 PREV_SECRET 也已移除, 就可以刪除 X-Signature-Prev 的分支。

緊急失效

若金鑰公開外洩,你想立刻讓前一個金鑰失效 — 也就是不希望 24 小時 重疊讓已知問題金鑰繼續活著 — 那就輪替兩次。第一次輪替把外洩金鑰 移到 prev 槽;第二次輪替把它從 prev 槽擠出(以仍然新的金鑰取代), 外洩值就再也不被接受。

配線測試

在儀表板開啟 Developers → Webhooks,於想驗證的端點點 Send Test。 我們會同步對 URL 簽章並 POST 一個合成信封,然後顯示 HTTP 狀態、 延遲與你回應的前 512 位元組。酬載形狀:

{ "event_id": "<uuid>", "event_type": "webhook.test.ping", "created_at": "2026-05-17T12:00:00Z", "test": true, "data": { "merchant_id": "<your-merchant-id>", "webhook_id": "<endpoint-id>", "message": "Test ping from the merchant dashboard..." } }

測試 ping 與正式投遞使用相同的簽章方案,所以此按鈕出現綠勾即可 確認你的驗證器也能接受真實事件。測試 ping 會繞過 RMQ 重試管線 — 若想演練重試,請透過對應的 API 流程觸發真實事件。

常見失敗

症狀可能原因
開發環境永遠回 falseHMAC 計算前 body 已被 JSON 解析。請先讀取原始位元組。
昨天還能用,今天失敗你輪替了金鑰但這台伺服器的 env var 還是舊的。請用新金鑰重新部署。
舊事件失敗,新事件成功一次投遞在輪替前已入佇列;簽章用舊金鑰而你的驗證器不再接受。請等重試自然丟棄,或透過儀表板重放。
時間戳比較差一確認以 Unix 秒比較 Unix 秒。JS 的 Date.now()毫秒 — 需除以 1000。
本機正常,正式失敗代理(Cloudflare、nginx)在解壓、重新編碼或剝掉了尾端換行。請檢查處理函式實際看到的位元組。
測試 ping 回 401 / 簽章不符你的驗證器只簽了 body(2026 年前的方案)。請改成簽 timestamp + "." + body
整個標頭遺失端點註冊在另一個環境下。Test 模式端點只接收 environment=test 事件。