安全2026年4月18日9 分钟阅读

验证 Stripe、GitHub 和 Shopify 的 Webhook 签名

跨平台的 Webhook 验签:哪里容易出错,时间戳容差怎么起作用,以及重试为什么会让简单的实现失效。

三个签名,你只需验证一个平台对原始字节签名Stripe-Signature边缘节点验证,持久化重新签名每次尝试新的时间戳你转发的平台请求头保留原始时间戳,所以重试时会过期AnyHook-Signature 每次尝试都重新签名,所以 5 分钟的时间容差始终成立
签名有三种。你的处理程序应该只需要验证其中一种。

如果你处理 Webhook 时不验证签名,互联网上的任何人都能向你的端点伪造一个 payment_intent.succeeded。这不是理论上的风险。这是 Webhook 集成中最常见的认证漏洞,攻击者也知道这一点。

这篇文章是一份写给一线工程师的签名验证指南:每个平台怎么做,实现中有哪些不易察觉的错法,以及怎么干净地处理重试时的时间戳。

TL;DR

  • 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 URL 是公开的。任何拿到这个 URL 的人都能向它 POST。签名证明两件事:请求来自平台而不是攻击者,以及请求体在传输途中没有被修改。

值得精确说明它没有覆盖什么,因为在这里,测试全绿这个证据比看上去要弱。你的 Webhook 签名测试什么也证明不了给出了一组测试用例,攻击者能直接绕过的验证代码,照样能让它们全部通过。而且签名完全说明不了事件到底有没有到达,这就是为什么漏掉的撤销 Webhook 是安全事故,而不是图表上的一个缺口,也是为什么记录了哪些事件确实到达的日志,值得做成可检测篡改的。

不做验证,任何猜到或发现你 Webhook URL 的人都能给你发假事件。做了验证,他们还需要签名密钥,而这个密钥只有你和平台有。

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 分钟之前,就拒绝(防止重放攻击)
  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不匹配数据相同数据相同。键的顺序不同,空白不同,而请求体在你的日志里看起来完全正常。
两个对象是同一个事件。只有其中一个算出的哈希还能对上平台发来的签名。

这里常见的错误是先把请求体解析成 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 小时内同时有效)

在 Serverless 上,原始请求体这一条最容易出问题,因为框架会在你的代码运行之前就把请求流读完。在 Vercel 上接收 Webhook,不让事件丢失给出了 App Router 和 Pages Router 两个版本的写法,包括会悄悄破坏它的 body parser。

总结

大多数平台对原始请求体做 HMAC-SHA256;Twilio、Intercom 和 Vercel 用 HMAC-SHA1,Discord、SendGrid 和 PayPal 用公钥签名。在用 HMAC 的平台之间,区别在于编码方式(十六进制还是 base64)以及是否包含时间戳,我们实现过的每一种方案都整理在 Webhook 平台参考的表格里。如果你想只验证一个请求头而不是三个,就把各个平台指向一个转发服务。AnyHook 负责扇入,并用一套统一的方案为每次对外投递重新签名。

全部文章2026年4月18日 · 9 分钟

别再丢 Webhook 了。

只改一个 URL。获得重试、事件日志和一键重放。也可以先亲眼看看:注册后跑一次 30 秒的故障演练,看一个事件如何挺过你的服务器宕机。不需要服务器。