Webhook 签名校验示例
Webhook 的本质是服务方主动调用你的回调地址。因为回调 URL 是公开的,任何人只要知道了地址就能伪造请求、塞进假数据。签名校验就是给请求加一道"身份证":用共享密钥对请求体做 HMAC,收到的请求必须带上匹配的签名才放行。下面用 Node.js 演示完整流程。
先想清楚一个问题:没有签名校验会发生什么?攻击者只要扫描到你的回调地址,就能伪造一条"用户已支付"的 webhook,如果业务逻辑里刚好"收到支付成功就发货",那就是白送商品。即便不发货,伪造的事件也可能污染统计、触发重复邮件、把数据库写脏。更隐蔽的攻击是重放:攻击者截获一条真实的支付回调,原样再发一次,如果服务端不去重,用户会被重复扣款或重复发货。所以签名校验至少要解决两件事:请求是不是来自你信任的服务方,以及请求是不是最新的、没有被重放过。
一个可运行的签名校验函数
const crypto = require('crypto');
function verifySignature(payload, signature, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(payload, 'utf8')
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature)
);
}
三个关键细节
第一,必须用原始请求体做校验。请求体一旦经过解析、格式化或转码,哈希结果就会变化,导致所有合法请求都被拒绝。用 Express 时注意:默认的 express.json() 中间件会把 body 解析成 JSON 对象,再 JSON.stringify 回去,字段顺序和空格都可能变,签名必然对不上。所以生产环境要保留原始 body 流,比如用 express.raw() 只对 webhook 路由生效,或者把原始 body 存到 req.rawBody。第二,timingSafeEqual 会以固定时间比较两个字符串,避免普通 === 比较产生的时序侧信道攻击——如果比较能提前结束,攻击者就能通过响应时间逐字节猜出签名。第三,签名通常放在请求头里(如 GitHub 的 X-Hub-Signature-256、Stripe 的 Stripe-Signature),拿到后要先做格式检查再参与比较。
主流平台的签名规则
| 平台 | 请求头 | 算法 | 前缀 |
|---|---|---|---|
| GitHub | X-Hub-Signature-256 |
HMAC-SHA256 | sha256= |
| Stripe | Stripe-Signature |
HMAC-SHA256(含时间戳) | t=...,v1=... |
| 微信支付 | Wechatpay-Signature |
RSA/SM2 | 无前缀 |
不同平台对"签名内容"的定义不完全一样。GitHub 是对原始 body 做 HMAC;Stripe 则把时间戳和 body 拼接后再签名,还要校验时间戳防重放。接入前务必先读对应平台的文档,确定签名内容和编码方式。
带时间戳的防重放校验(Stripe 风格)
有些平台把时间戳放进签名头,就是为了让你能做重放防护。以 Stripe 的格式为例,签名头长这样:t=1700000000,v1=...。校验时先看时间戳 t,如果和服务器当前时间差超过 5 分钟,直接拒绝——这能挡住"截获后重放旧请求"的攻击;时间戳通过后再按 v1=时间戳.原始body 重新计算 HMAC 比对。
function verifyStripeSignature(payload, header, secret) {
const parts = Object.fromEntries(
header.split(',').map(p => p.split('='))
);
const timestamp = parseInt(parts.t, 10);
// 时间戳过期检查(5 分钟)
if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
const signed = `${timestamp}.${payload}`;
const expected = crypto
.createHmac('sha256', secret)
.update(signed, 'utf8')
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected), Buffer.from(parts.v1)
);
}
密钥轮换与版本化
签名校验的强度取决于共享密钥本身,所以密钥也要定期轮换。轮换时最怕的是"换密钥瞬间,旧的合法请求全部被拒"。安全的做法是双密钥发布:先在服务端同时接受新旧两个密钥(旧密钥只用于校验、不用于签发),等平台侧全部切换到新密钥后,再移除旧的。很多平台的签名头里带版本号(如 Stripe 的 v1),就是为这种平滑轮换设计的。同时,密钥泄露时要能第一时间吊销并重签,所以建议把密钥集中放在密钥管理服务里,并开启审计日志。
完整回调处理流程
const express = require('express');
const app = express();
app.post('/webhook', (req, res) => {
const signature = req.headers['x-hub-signature-256'] || '';
const raw = JSON.stringify(req.body); // 生产环境应使用原始 body 流
if (!verifySignature(raw, signature.replace(/^sha256=/, ''), process.env.WEBHOOK_SECRET)) {
return res.status(401).json({ error: 'invalid signature' });
}
// 校验通过才进入业务逻辑
handleEvent(req.body);
res.status(200).json({ received: true });
});
失败时怎么处理
校验失败应当立即返回 401,不进入任何业务逻辑,同时记录来源 IP、请求头和失败时间用于审计。多次失败可以考虑临时拉黑来源地址。敏感密钥建议放在环境变量或密钥管理服务里,不要写死在代码或提交到仓库,相关做法可参考环境变量与密钥管理。想了解更完整的回调接入流程,可以看Webhook 集成指南,配套的接口安全可以对照API 安全与认证。
本地测试怎么造签名
本地联调时,平台不会真的往 localhost 发请求,可以用 OpenSSL 一行命令模拟出合法签名:
# 用共享密钥对 body 文件计算 HMAC-SHA256
printf '{"event":"payment.succeeded"}' | \
openssl dgst -sha256 -hmac 'your-secret' -hex
# 得到结果形如 sha256=xxxx...
再配合 ngrok 把本地端口暴露成公网地址,填到平台的 webhook 配置里,就能端到端调试完整流程。
常见问题
- 为什么校验失败要返回 401 而不是 404? 返回 404 可能让服务方误以为地址配置错了而反复重试,401 的语义更明确。
- 密钥怎么分发? 用环境变量或密钥管理服务,按环境区分,永不入库。
- 同一个事件收到两遍怎么办? 校验只解决"是不是伪造",不解决"是不是重复"。业务侧还要用事件 ID 去重,比如在数据库里记录已处理的事件 ID,重复的直接跳过。
- 请求体很大,校验性能会受影响吗? HMAC 计算是 O(n) 的,对正常请求体规模(几 KB 到几百 KB)开销可以忽略,不必担心。
参考
参考:GitHub Webhook 安全文档 https://docs.github.com/zh/webhooks
参考:Stripe Webhook 签名验证 https://docs.stripe.com/webhooks/signatures