如果你处理 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。
验证步骤:
- 从请求头中解析出
t和各个v1值 - 如果
t早于大约 5 分钟之前,就拒绝(防止重放攻击) - 计算
HMAC_SHA256(signing_secret, t + "." + raw_body) - 用恒定时间比较逐个对比
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));
}
这里常见的错误是先把请求体解析成 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 负责扇入,并用一套统一的方案为每次对外投递重新签名。