簽章驗證
每次 Webhook 投遞都會帶上兩個搭配使用的標頭:
X-Signature: sha256=9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b
X-Timestamp: 1729536000sha256= 後的十六進位字串是 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' })。
實作範例
Node / 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 等高價值事件推薦):
- 以
X-Delivery去重,使用帶唯一限制的資料表。容差視窗內的 重放會變成 no-op — 你的處理函式回 200 而不會做兩次。這正是 合法重試也需要的冪等性。(X-Delivery在一筆投遞的所有重試間保持穩定; 酬載中並沒有event_id欄位。) - 使用時鐘漂移所允許的最小容差。 ±5 分鐘是推薦預設,符合 多數 NTP 同步機群可維持的範圍。更嚴格也可以;但低於 ±30 秒後, 在上游 NTP 較慢的網路上你會開始拒絕合法的投遞。
輪替金鑰
- 儀表板 → Developers → Webhooks → [endpoint] → ⋮ → Rotate Secret。
- 產生新金鑰並僅顯示一次。關閉對話框前請複製。
- 在 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 流程觸發真實事件。
常見失敗
| 症狀 | 可能原因 |
|---|---|
| 開發環境永遠回 false | HMAC 計算前 body 已被 JSON 解析。請先讀取原始位元組。 |
| 昨天還能用,今天失敗 | 你輪替了金鑰但這台伺服器的 env var 還是舊的。請用新金鑰重新部署。 |
| 舊事件失敗,新事件成功 | 一次投遞在輪替前已入佇列;簽章用舊金鑰而你的驗證器不再接受。請等重試自然丟棄,或透過儀表板重放。 |
| 時間戳比較差一 | 確認以 Unix 秒比較 Unix 秒。JS 的 Date.now() 是毫秒 — 需除以 1000。 |
| 本機正常,正式失敗 | 代理(Cloudflare、nginx)在解壓、重新編碼或剝掉了尾端換行。請檢查處理函式實際看到的位元組。 |
| 測試 ping 回 401 / 簽章不符 | 你的驗證器只簽了 body(2026 年前的方案)。請改成簽 timestamp + "." + body。 |
| 整個標頭遺失 | 端點註冊在另一個環境下。Test 模式端點只接收 environment=test 事件。 |