資安2026年4月18日閱讀時間 9 分鐘

驗證 Stripe、GitHub 和 Shopify 的 Webhook 簽章

跨服務商驗證 Webhook 簽章:常見的錯誤、時間戳記的容許時間差怎麼運作,以及為什麼重試會弄壞簡單的實作。

三個簽章,你要驗證的只有一個服務商簽署原始位元組Stripe-Signature邊緣節點驗證、持久寫入重新簽章每次嘗試新的時間戳記你轉送的服務商標頭沿用原本的時間戳記,所以重試時會過期AnyHook-Signature 每次嘗試都重新簽章,所以 5 分鐘的容許時間差永遠成立
簽章有三種。你的處理程式應該永遠只需要檢查一種。

如果你處理 Webhook 時沒有驗證簽章,網路上任何人都能偽造一個 payment_intent.succeeded 打到你的端點。這不是理論上的風險。這是 Webhook 整合裡最常見的驗證漏洞,攻擊者也都知道。

這篇是寫給實務工程師的簽章驗證指南:每個服務商怎麼做、實作上容易出錯的細微之處,以及怎麼乾淨地處理重試時的時間戳記。

重點摘要

  • Stripe、GitHub 和 Shopify 都對原始請求主體的位元組計算 HMAC-SHA256。計算雜湊前,不要把 JSON 重新序列化。
  • Stripe 在 Stripe-Signature 裡送出時間戳記 t 和以十六進位編碼的簽章 v1,時間戳記的容許時間差是 5 分鐘。
  • GitHub 在 X-Hub-Signature-256 裡送出 sha256=…,沒有時間戳記。
  • Shopify 在 X-Shopify-Hmac-SHA256 裡送出 base64 編碼的 HMAC-SHA256。
  • 一律用常數時間比較(crypto.timingSafeEqual),絕對不要用 ===。
  • 如果重試弄壞了你的時間戳記檢查,你需要一個每次嘗試都重新簽章的 Webhook 中繼服務(webhook relay)。

服務商為什麼要替 Webhook 簽章

你的 Webhook 網址是公開的。任何知道網址的東西,都能對它 POST。簽章證明兩件事:請求來自服務商而不是攻擊者,以及請求主體在傳輸途中沒有被修改。

值得精確說明它沒有涵蓋什麼,因為在這裡,一整排綠燈的測試,證據力比看起來弱。你的 Webhook 簽章測試什麼都證明不了列出了一些測試資料,它們能通過的驗證程式碼,攻擊者可以長驅直入。而且簽章完全無法說明事件到底有沒有送達,這就是為什麼漏掉一個撤銷權限的 Webhook,是資安事故,而不是圖表上的一個缺口,也是為什麼記錄實際收到哪些事件的事件紀錄,值得做成可偵測竄改。

沒有驗證,任何猜到或發現你 Webhook 網址的人,都能送假事件給你。有了驗證,對方還需要簽章密鑰,而只有你和服務商有。

Stripe

Stripe 會送出一個 Stripe-Signature 標頭,長這樣:

t=1617223220,v1=3b8a92b...,v1=9d7ac...

t 是送出 Webhook 時的時間戳記。v1 是 {timestamp}.{raw_body} 的 HMAC-SHA256,以十六進位編碼。輪換金鑰期間,會有多個 v1。

驗證步驟:

  1. 從標頭解析出 t 和各個 v1 值。
  2. t 如果已經超過大約 5 分鐘,就拒絕,以防範重放攻擊(replay attack)。
  3. 計算 HMAC_SHA256(signing_secret, t + "." + raw_body)。
  4. 用常數時間比較逐一比對每個 v1,任何一個相符就接受。
import crypto from "node:crypto";

// timingSafeEqual throws on inputs of different lengths, so check that first
function safeEqual(a: string, b: string): boolean {
  const x = Buffer.from(a);
  const y = Buffer.from(b);
  return x.length === y.length && crypto.timingSafeEqual(x, y);
}

function verifyStripe(body: string, header: string, secret: string): boolean {
  const parts = Object.fromEntries(
    header.split(",").map((p) => p.split("=").slice(0, 2) as [string, string])
  );
  const t = parts.t;
  const v1s = header.match(/v1=([a-f0-9]+)/g)?.map((x) => x.slice(3)) ?? [];

  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${t}.${body}`)
    .digest("hex");

  return v1s.some((sig) => safeEqual(expected, sig));
}
HMAC 算的是位元組,不是語意原始位元組{"id":"evt_1","n":2}req.text()hmac相符解析後再轉回字串{ "n": 2, "id": "evt_1" }req.json()hmac不相符同樣的資料同樣的資料,不同的鍵順序、不同的空白,而且 payload在你的 log 裡看起來完全正常。
兩個物件是同一個事件,但只有其中一個,算出來的雜湊還符合服務商送來的簽章。

這裡常見的錯誤,是先把請求主體解析成 JSON,再轉回字串。這會改變空白。簽章是對原始位元組計算的,所以你必須拿收到的確切位元組來驗證。

GitHub

GitHub 會同時送出 X-Hub-Signature(SHA1,舊版)和 X-Hub-Signature-256(SHA256)。一律使用 256 的版本。

格式:sha256=3b8a92b...

驗證:

function verifyGitHub(body: string, header: string, secret: string): boolean {
  const expected = "sha256=" + crypto
    .createHmac("sha256", secret)
    .update(body)
    .digest("hex");
  return safeEqual(expected, header);
}

比 Stripe 簡單,沒有時間戳記,也沒有重放攻擊防護。GitHub 靠的是密鑰不外洩。

Shopify

Shopify 送出的 X-Shopify-Hmac-SHA256,是原始請求主體的 HMAC-SHA256,以 base64 而不是十六進位編碼。

function verifyShopify(body: string, header: string, secret: string): boolean {
  const expected = crypto
    .createHmac("sha256", secret)
    .update(body)
    .digest("base64");
  return safeEqual(expected, header);
}

陷阱和 Stripe 一樣:計算雜湊前讓請求主體經過 JSON 序列化,就會壞掉。對原始位元組計算雜湊。

重試與時間戳記的問題

Stripe 時間戳記的容許時間差是 5 分鐘。這代表如果 Stripe 在第一次嘗試的 10 分鐘後,帶著原本的標頭重試一個事件,照重試邏輯自己的標準,這個時間戳記已經過期了。

實際上,Stripe 每次重試都會用新的 t 重新產生簽章,所以沒問題。但在會快取再重播、或是位於佇列後面的系統裡,你最後可能會重播帶著過期時間戳記的事件。解法是在你重播的那一層,用自己的簽章金鑰和新的時間戳記重新簽章。

AnyHook 做的正是這件事。服務商原本的標頭會原封不動地轉送,所以你的驗證程式碼在第一次嘗試時照樣能用(重試或重播時,這些標頭帶的是服務商原本的時間戳記)。但 AnyHook 也會用自己的 AnyHook-Signature 標頭,替每一次對外傳遞簽章。這個標頭是 HMAC-SHA256,以十六進位編碼,格式和 Stripe 相容:t={ts},v1={hex}。每次重試都會用新的時間戳記重新簽章,所以即使在漫長的連續重試中,5 分鐘的容許時間差也能正確運作;而且它以每個目的地各自的簽章密鑰計算,所以一個端點的密鑰外洩,不會波及其他端點。

function verifyAnyHook(body: string, header: string, secret: string): boolean {
  const t = header.match(/t=(\d+)/)?.[1];
  const v1 = header.match(/v1=([a-f0-9]+)/)?.[1];
  if (!t || !v1) return false;
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${t}.${body}`)
    .digest("hex");
  return safeEqual(expected, v1);
}

只要一個驗證函式,而不是三個,重試時也不會有時間戳記的怪問題。服務商照樣替原始請求簽章,AnyHook 在邊緣節點驗證它,再用自己的新簽章,替每次傳遞重新簽章。

檢查清單:要做對的事

  • 用原始請求主體的位元組驗證,不要用重新序列化的 JSON。
  • 用常數時間比較(crypto.timingSafeEqual),不要用 ===。
  • 缺少簽章標頭的請求要拒絕,不要預設「放行」。
  • 時間戳記容許時間差:標準是 5 分鐘。更短比較安全,但會擋掉正當的重試。
  • 簽章密鑰儲存時要加密,不要放在開發者筆電的環境變數裡。
  • 定期輪換簽章密鑰,並處理好輪換期間(新舊金鑰都有效 24 小時)。

在無伺服器環境,最容易出事的是原始請求主體這一項,因為框架會在你的程式碼執行前就把串流讀完。在 Vercel 上接收 Webhook,不讓事件遺失有 App Router 和 Pages Router 的版本,其中也包括會悄悄弄壞它的 body parser。

結論

多數服務商對原始請求主體計算 HMAC-SHA256;Twilio、Intercom 和 Vercel 用 HMAC-SHA1,Discord、SendGrid 和 PayPal 則用公開金鑰簽章。在使用 HMAC 的服務商之間,差別在於編碼(十六進位或 base64),以及是否包含時間戳記;我們實作過的每一種方式,都整理在 Webhook 服務商參考資料的表格裡。如果你只想驗證一個標頭,而不是三個,就把服務商都指向 Webhook 中繼服務。AnyHook 負責把多個來源匯集起來,並用單一、一致的方式,替每次對外傳遞重新簽章。

所有文章2026年4月18日 · 9 分鐘

別再漏掉 Webhook。

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