工程2026年7月30日閱讀時間 7 分鐘

你的 Webhook 簽章測試什麼都證明不了

如果測試用驗證程式檢查的同一套公式產生簽章,兩者可能一起錯,測試卻照樣全綠。改用服務商的 SDK 交叉驗證。

通過的簽章測試實際涵蓋了什麼你的 fixture → 你的驗證程式兩者都建立在同一個假設上真正的請求解析過的主體 · 代理伺服器改寫 · 重試的時間戳記 · 輪替過的金鑰測試從來不會碰到在你的程式碼執行前就讀走串流的那一層唯一能證明什麼的測試,是讓一次真正簽章的傳遞走過真正的整套系統
測試和受測的程式碼,通常共用同一個錯誤的假設。

下面這個 Webhook 簽章測試會通過,看起來很負責,卻幾乎什麼都沒證明:

const ts  = Math.floor(Date.now() / 1000).toString();
const sig = await hmacHex(`${ts}.${body}`, secret, "SHA-256");
const headers = new Headers({ "stripe-signature": `t=${ts},v1=${sig}` });

expect(await verifySignature("stripe", headers, body, secret)).toBe(true);

這個測試資料(fixture)是用驗證程式所檢查的同一套公式產生的。如果你在寫驗證程式時讀錯了 Stripe 的文件(簽錯了字串、用錯了分隔符號、該用 base64 的地方用了 hex),測試就會寫進同樣的誤讀,兩者一起維持綠燈。你測的不是「我們能驗證 Stripe 簽章」,而是「我們同意我們自己」。

我們有 70 個像這樣的測試,橫跨 20 個服務商。每一個都通過。接著我們問了唯一重要的問題:服務商自己的程式碼產生的簽章,能通過我們的驗證程式嗎?

HMAC 算的是位元組,不是語意原始位元組{"id":"evt_1","n":2}req.text()hmac相符解析後再轉回字串{ "n": 2, "id": "evt_1" }req.json()hmac不相符同樣的資料同樣的資料,不同的鍵順序、不同的空白,而且 payload在你的 log 裡看起來完全正常。
fixture 重現不了的失敗:你的框架已經讀掉了串流,所以你拿去算雜湊的位元組,不是當初被簽章的那些位元組。

用服務商的 SDK 交叉驗證

有些服務商會在官方 SDK 裡附上簽章函式,通常就是為了這個目的。Stripe 的是 generateTestHeaderString;Svix 的 Webhook 類別有 .sign()。如果它們本來就是你的相依套件,交叉驗證完全不用成本:

import Stripe from "stripe";
import { Webhook } from "svix";
import { verifySignature } from "./index";

it("accepts a header generated by stripe-node", async () => {
  const stripe  = new Stripe("sk_test_" + "0".repeat(24));
  const payload = JSON.stringify({ id: "evt_1", type: "charge.succeeded" });
  const secret  = "whsec_" + "s".repeat(32);

  const header = stripe.webhooks.generateTestHeaderString({ payload, secret });

  expect(
    await verifySignature("stripe", new Headers({ "stripe-signature": header }), payload, secret)
  ).toBe(true);
});

it("accepts a signature generated by the svix SDK", async () => {
  const secret  = "whsec_" + Buffer.from("k".repeat(24)).toString("base64");
  const payload = JSON.stringify({ type: "email.received" });
  const msgId   = "msg_crosscheck";
  const ts      = new Date();

  const signature = new Webhook(secret).sign(msgId, ts, payload);
  const headers = new Headers({
    "svix-id": msgId,
    "svix-timestamp": Math.floor(ts.getTime() / 1000).toString(),
    "svix-signature": signature,
  });

  expect(await verifySignature("svix", headers, payload, secret)).toBe(true);
});

現在斷言的是我們和 Stripe 一致,而不是我們和自己一致。再加上反向案例(簽一個 payload,然後驗證另一個主體),確認這個測試真的會失敗:

const header   = stripe.webhooks.generateTestHeaderString({ payload, secret });
const tampered = JSON.stringify({ id: "evt_1", amount: 999999 });

expect(
  await verifySignature("stripe", new Headers({ "stripe-signature": header }), tampered, secret)
).toBe(false);

因為 Clerk 和 Resend 都透過 Svix 的基礎設施傳遞,一個 Svix 交叉驗證就涵蓋三個服務商。我們的二十個驗證程式裡,現在有四個有 SDK 背書的證明,而同樣一個下午的工作,適用於任何技術堆疊。

沒有公開簽章函式的服務商怎麼辦?

Shopify、Discord、PayPal:大多數服務商都沒有附簽章函式。對這些服務商來說,唯一誠實的證據是擷取下來的真實傳遞。把一個 Webhook 指向你能控制的端點,錄下一個簽過章的請求,再把 (headers, body, secret) 這組三元組凍結成 fixture。重播一個真實的簽章,就是任何自製 fixture 都無法成為的基準事實(ground truth)。

在做到這一步之前,要對自己精確地說清楚,測試到底證明了什麼。我們把這份帳寫成註解,放在測試檔案的最底下:哪些服務商經過 SDK 驗證、哪些只是自我一致(self-consistent)、哪些根本沒有正向測試。一份能據以行動的誠實清單,勝過一套你無法信任的綠燈測試。

這種心態當天就找到的 bug

同樣的懷疑也適用於單元測試之外。我們對正式環境端對端驗證 Resend 路徑時(不是單元測試套件,而是真正部署出去的系統),對一個設定了簽章密鑰的端點送了三個請求:

請求預期實際
正確的簽章200200
錯誤的簽章401401
完全沒有簽章401200

一個沒有簽章的請求,順利穿過了一個看似受保護的端點。原因是政策上的漏洞,不是加密的 bug。來源為 generic 的 App(一個接收網址,對應一個 Webhook 來源)依設計會跳過驗證(「我只是想要一個網址」),而設定密鑰並沒有讓 App 脫離 generic。密鑰就擺在那裡,什麼都沒驗證,API 卻對儲存它的請求回傳成功。

再多自我一致的單元測試也找不到這個問題,因為每個單元都是正確的。修正方式是直接拒絕這種會誤導人的設定:現在對 generic App 設定簽章密鑰,會得到一個 400,告訴你應該改設哪個欄位。

檢查清單

  1. SDK 有附簽章函式的服務商:交叉驗證,兩個方向都要(有效的通過、竄改的失敗)
  2. 其他服務商:擷取一次真實的簽章傳遞,凍結成 fixture
  3. 維護一份看得見的清單,記錄每個服務商有哪個等級的證明
  4. 用缺少簽章的請求測試部署出去的系統,而不是只測錯誤的簽章。繞過驗證的漏洞藏在政策裡,不在數學裡
所有文章2026年7月30日 · 7 分鐘

別再漏掉 Webhook。

換一個網址,就有重試、事件紀錄和一鍵重播。或者先親眼看看:註冊後跑一次 30 秒的當機演練,看著一個事件在伺服器當機時保存下來。不需要伺服器。