要验证 Webhook 签名,需要使用共享密钥对原始请求体计算 HMAC-SHA256 摘要,再以恒定时间(constant time)方式将其与发送方放在请求头中的签名进行比较。如果两者不一致,则拒绝该请求。
你的处理程序已经在生产环境中稳定运行了好几周。可是一加上签名校验,每次投递都报 “invalid signature”,而密钥明明是正确的。
Webhook 端点是一个公开的 URL,任何人都可以向它发送 POST 请求。如果不做签名校验,处理程序就会信任收到的任何内容。如果你需要先回顾一下这种模式,可以参阅 OpenReplay 的 Webhook 指南,其中介绍了基础知识。本文将为 Stripe 和 GitHub 构建一个正确的 Express 处理程序,内容涵盖原始请求体中间件的注册顺序、恒定时间比较、重放攻击防御以及双密钥轮换校验,并解释每一步存在的原因。
核心要点
- Webhook 签名是对请求原始字节计算出的 HMAC-SHA256 摘要,因此在校验之前对 JSON 进行任何解析和重新序列化,都会导致校验失败。
- 在 Express 中,应在任何全局
app.use(express.json())之前,使用express.raw({ type: 'application/json' })注册 Webhook 路由。最先运行的请求体解析器会消费掉请求流。 - 当两个 Buffer 长度不同时,
crypto.timingSafeEqual会抛出异常,因此要先比较长度,并将长度不一致视为签名无效。 - Stripe 对
${t}.${rawBody}进行签名,并且可以为每个有效密钥各发送一个v1签名。GitHub 只对原始请求体签名,且不包含时间戳,因此需要基于投递 ID 进行去重。
Webhook 签名验证的工作原理
Webhook 签名是发送方使用一个只有你和发送方持有的密钥,对原始请求体计算出的 HMAC 摘要。摘要匹配可以证明两件事:载荷未被篡改,并且它来自持有该密钥的一方。HMAC 将密钥融入 SHA-256 哈希运算中,因此没有密钥的人无法为给定的请求体生成有效摘要。你的服务器重复同样的计算,然后比较结果。
本文涉及的两家服务商使用相同的底层原语,但签名的字符串不同:
| Stripe | GitHub | |
|---|---|---|
| 请求头 | Stripe-Signature: t=<unix>,v1=<hex> | X-Hub-Signature-256: sha256=<hex> |
| 签名字符串 | ${t}.${rawBody} | 原始请求体 |
| 编码 | hex | hex |
| 是否对时间戳签名 | 是 | 否 |
| 重放防御 | 拒绝过期的 t | 基于 X-GitHub-Delivery 去重 |
Stripe 在文档中说明了用于手动验证的请求头格式和签名载荷。测试事件还会附带一个伪造的 v0 签名,因此除 v1 之外的所有方案都应忽略。GitHub 在验证 Webhook 投递中描述了其签名方案:该值始终以 sha256= 开头。GitHub 仍会发送旧的 SHA-1 X-Hub-Signature 请求头,但保留它只是为了让旧的集成继续可用。
在任何 JSON 解析器之前读取原始请求体
Webhook 签名验证反复失败,最常见的原因是 JSON 解析器在校验之前就运行了。HMAC 验证是逐字节精确比对的:如果你对解析后的对象重新序列化,空白字符、键的顺序或转义方式上的任何变化,都会导致摘要永远无法匹配。
问题通常出在中间件的注册顺序上。在 Express 中,应在任何全局 app.use(express.json()) 之前,使用 express.raw() 注册 Webhook 路由。一旦 JSON 解析器消费了请求流,原始字节就不复存在了:
// Broken: the global JSON parser runs first, so req.body is a parsed object
app.use(express.json());
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), handler);
// Fixed: webhook routes first, global JSON parser after
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), handler);
app.use(express.json());
在错误的写法中,即使给路由加上 express.raw() 也无济于事。在 body-parser 中,最先运行的解析器会读取请求流并将请求标记为已解析,之后运行的解析器都会跳过该请求。因此,路由级别的 raw 解析器什么也拿不到,req.body 仍然是 express.json() 生成的对象。
在处理程序中加入 Buffer.isBuffer(req.body) 守卫检查,这样可以把具有误导性的”签名不匹配”错误转变为一目了然的配置错误。本文代码基于 Express 5。在 Express 5 中,当内容类型与解析器不匹配时,req.body 为 undefined;而在 Express 4 中则为 {}。无论哪种情况,该守卫检查都会返回 400。
如果无法调整中间件顺序,可以使用 express.json() 的 verify 选项,它会在解析前提供原始 Buffer,你可以将其保存到 req 上供后续使用。在 Next.js App Router 的路由处理程序中,你的代码之前不会运行任何请求体解析器,因此可以使用 Buffer.from(await request.arrayBuffer()) 读取字节,完成验证后再调用 JSON.parse。
计算预期签名并进行恒定时间比较
使用 crypto.createHmac('sha256', secret) 计算摘要,并直接传入 Buffer。先将请求体解码为字符串再重新编码,会多出一个可能导致字节变化的环节。对于 Stripe,可以链式调用 .update(`${t}.`).update(rawBody),这样请求体就能原封不动地进入哈希运算。
Webhook 签名必须以恒定时间进行比较,而不能使用 ===。字符串比较可能在遇到第一个不同字符时就立即返回,因此其运行时间会泄露猜测值中有多少部分是正确的。crypto.timingSafeEqual(a, b) 无论两个 Buffer 在何处不同,耗时都相同。此外,当两个 Buffer 长度不同时,它会抛出异常。因此要先检查长度,让格式错误的请求头被视为签名无效,而不是让处理程序崩溃并返回 500:
function safeEqualHex(expectedHex, receivedHex) {
const a = Buffer.from(expectedHex, 'hex');
const b = Buffer.from(receivedHex, 'hex');
if (a.length === 0 || a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
当签名校验持续失败时,请按以下顺序排查:
req.body是否为 Buffer(打印Buffer.isBuffer(req.body)的结果)。- 密钥是否确实属于当前端点及当前模式。每个 Stripe 端点都有各自的密钥,同时用于测试模式和正式模式的端点,在两种模式下的密钥也各不相同。
- 如果 GitHub 根本没有发送
X-Hub-Signature-256请求头,说明该 Webhook 未配置密钥。同时,请确认你读取的是 SHA-256 请求头,而不是旧版的 SHA-1 请求头,具体可参考 GitHub 故障排查页面。 - 签名字符串是否正确:Stripe 需要
t.前缀,而 GitHub 的sha256=前缀必须去掉。 - 双方是否都使用 hex 编码。将日志中记录的原始请求体和你的密钥粘贴到 OpenReplay HMAC 生成器中,手动将生成的摘要与请求头中的值进行比对。
利用时间戳或投递 ID 拒绝重放请求
有效的 Webhook 签名只能证明请求由谁发送,而无法证明何时发送。因此,一个被截获的已签名请求可以在之后被重放。对于 Stripe,应拒绝签名时间戳距今超过约五分钟的投递,这与 Stripe 官方库的默认容差一致。对于不对时间戳签名的 GitHub,则应记录每个投递 ID 并跳过重复的投递。
先校验签名,再校验时间戳。在 t 上的 HMAC 通过验证之前,t 只是攻击者可以随意设置的文本。此外,要将非数字的 t 视为校验失败:与 NaN 进行比较的结果总是 false,会悄无声息地绕过简单的时效性检查。
GitHub 的 X-GitHub-Delivery 请求头为每个事件携带一个 GUID,而重新投递时会保留相同的 GUID。只有在处理成功之后才应记录该 ID。GitHub 不会自动重试失败的投递,并且如果 10 秒内未收到 2xx 响应,就会将该投递视为失败。如果你在处理失败之前就记录了 ID,那么之后手动触发的重新投递就会被跳过。
在密钥轮换期间同时接受两个密钥
在 Webhook 密钥轮换期间,只要任一当前有效的密钥计算出的摘要与请求头中的任一 v1 值匹配,就应接受该请求。当你轮换 Stripe 端点密钥时,旧密钥可以在你设定的一段时间内(最长 24 小时)保持有效。在其过期之前,每次投递都会为每个仍然有效的密钥分别携带一个 v1 签名。
如果 Stripe 请求头解析器将请求头折叠成一个对象,就只会保留一个 v1,从而拒绝合法的投递。因此应将所有值收集到一个数组中。对于 GitHub,则用每个已配置的密钥去匹配唯一的签名请求头。some() 循环在匹配成功时会提前返回,但这不会泄露任何有用的信息:每一次单独的比较仍然是恒定时间的。
完整的 Express 处理程序
下面这个 ESM 处理程序整合了上述所有步骤。对于任何签名或格式错误,它都返回 400(发送方只区分 2xx 与非 2xx);对于 GitHub 的重复投递,则返回 200,即确认接收但跳过处理:
import express from 'express';
import crypto from 'node:crypto';
const app = express();
const TOLERANCE_SECONDS = 300;
// Current secret first, previous secret during rotation
const STRIPE_SECRETS = [
process.env.STRIPE_WEBHOOK_SECRET,
process.env.STRIPE_WEBHOOK_SECRET_PREVIOUS,
].filter(Boolean);
const GITHUB_SECRETS = [
process.env.GITHUB_WEBHOOK_SECRET,
process.env.GITHUB_WEBHOOK_SECRET_PREVIOUS,
].filter(Boolean);
if (STRIPE_SECRETS.length === 0 || GITHUB_SECRETS.length === 0) {
throw new Error('Webhook secrets are not configured');
}
function safeEqualHex(expectedHex, receivedHex) {
const a = Buffer.from(expectedHex, 'hex');
const b = Buffer.from(receivedHex, 'hex');
if (a.length === 0 || a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
function parseStripeHeader(header) {
let timestamp = null;
const signatures = [];
for (const part of header.split(',')) {
const i = part.indexOf('=');
if (i === -1) continue;
const key = part.slice(0, i).trim();
const value = part.slice(i + 1).trim();
if (key === 't') timestamp = value;
else if (key === 'v1') signatures.push(value); // keep every v1
}
return { timestamp, signatures };
}
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.get('Stripe-Signature');
if (!header || !Buffer.isBuffer(req.body)) {
return res.status(400).send('Missing signature or raw body');
}
const { timestamp, signatures } = parseStripeHeader(header);
if (!timestamp || signatures.length === 0) {
return res.status(400).send('Malformed signature header');
}
const valid = STRIPE_SECRETS.some((secret) => {
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.`)
.update(req.body)
.digest('hex');
return signatures.some((sig) => safeEqualHex(expected, sig));
});
if (!valid) return res.status(400).send('Invalid signature');
// Only trust t after the HMAC over it has been verified
const age = Math.floor(Date.now() / 1000) - Number(timestamp);
if (!Number.isFinite(age) || Math.abs(age) > TOLERANCE_SECONDS) {
return res.status(400).send('Timestamp outside tolerance');
}
const event = JSON.parse(req.body.toString('utf8'));
// Hand event off to a queue; respond fast
res.sendStatus(200);
});
const seenDeliveries = new Set(); // Use a shared store with a TTL in production
app.post('/webhooks/github', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.get('X-Hub-Signature-256');
if (!header?.startsWith('sha256=') || !Buffer.isBuffer(req.body)) {
return res.status(400).send('Missing signature or raw body');
}
const received = header.slice('sha256='.length);
const valid = GITHUB_SECRETS.some((secret) =>
safeEqualHex(
crypto.createHmac('sha256', secret).update(req.body).digest('hex'),
received,
),
);
if (!valid) return res.status(400).send('Invalid signature');
const deliveryId = req.get('X-GitHub-Delivery');
if (!deliveryId) return res.status(400).send('Missing delivery ID');
if (seenDeliveries.has(deliveryId)) return res.sendStatus(200);
const payload = JSON.parse(req.body.toString('utf8'));
// Process payload, then record the ID only after success
seenDeliveries.add(deliveryId);
res.sendStatus(200);
});
app.use(express.json()); // Everything else, after the webhook routes
app.listen(3000);
每一处守卫检查都针对一种特定的故障:缺少请求头时返回 400 而不是抛出异常;所有 v1 值都会被保留;在调用 timingSafeEqual 之前先检查长度;非 Buffer 类型的请求体会被识别为配置错误。对于只接入 Stripe 的应用,官方库中的 stripe.webhooks.constructEvent 会替你完成同样的检查,Stripe Webhook 文档中介绍了其调用方法。
总结
Webhook 签名验证归根结底就是:对实际收到的原始字节进行哈希,并以安全的方式比较结果。处理程序中的其余部分,都是为了保证这些字节不被改动、阻止重放攻击,以及平稳度过密钥轮换。首先,将你的 Webhook 路由移到 express.json() 之前,然后再加入上面的处理程序。如果你仍在考虑推送式投递是否适合你的集成场景,可以阅读 Webhook 与轮询对比一文,其中比较了两者的利弊。
常见问题
为什么使用 Stripe CLI 在本地测试时,Stripe 签名验证会失败?
Stripe CLI 使用自己的签名密钥,它与在 Stripe 控制台(Dashboard)中创建的任何端点的密钥都不同。两者都以 whsec_ 开头,因此很容易混淆。运行 stripe listen 时,CLI 会在终端中打印其密钥。对于 CLI 转发的事件,请使用该密钥;切勿使用控制台端点密钥来验证 CLI 转发的事件,反之亦然。
应该使用 Stripe 的 constructEvent,还是手动验证签名?
如果 Stripe 是你唯一的服务商,请使用 stripe.webhooks.constructEvent。你只需传入原始请求体、Stripe-Signature 请求头的值以及端点密钥,当签名校验不通过时,它会抛出错误。默认情况下,Stripe 官方库会拒绝与当前时间相差超过 5 分钟的签名时间戳,你也可以通过一个可选参数设置不同的时间窗口。如果 GitHub 或其他服务商与 Stripe 共用同一条代码路径,则手动验证更为合适。
为什么要使用 HMAC,而不是直接用 SHA-256 对密钥和请求体一起做哈希?
HMAC 能够抵御长度扩展攻击,而诸如“对密钥拼接请求体后计算 SHA-256”这样的简单构造则无法抵御这种攻击。SHA-256 采用 Merkle-Damgård 结构,因此任何持有有效摘要的人,都可以在不知道密钥的情况下向消息追加数据,并为更长的消息计算出有效摘要。HMAC 通过两轮嵌套哈希处理密钥,从而阻断了这种攻击。Stripe 和 GitHub 都使用 HMAC-SHA256 进行签名。
如果已经验证了 Webhook 签名,还需要使用 HTTPS 吗?
需要。HMAC 签名保护的是完整性和真实性,而不是机密性。如果没有 TLS,网络路径上的任何人都可以读取载荷(其中可能包含客户或支付数据),还可以截获已签名的请求以便日后重放。签名校验、重放防御和 HTTPS 各自应对不同的威胁,因此生产环境中的 Webhook 端点三者缺一不可。