Webhook 验签最折磨人的情况,是 Secret 看起来没错,Header 也确实收到了,自己算出的摘要却始终差一截。继续换库通常没有用,因为多数问题不在 SHA-256,而在“究竟对哪一串字节签名”。
Webhook 的 JSON 只要被解析过,就不一定还是服务商发送的原文。下面两个 Body 表达的是同一件事,签名却完全不同:
{"id":123,"paid":true}{
"paid": true,
"id": 123
}空格、换行、字段顺序和末尾换行都属于字节的一部分。正确顺序是先读取并保留 raw body,完成签名验证以后,再做 JSON 解析和业务处理。
先确认服务商的签名协议#
Stripe、GitHub、Shopify 和 Slack 都常使用 HMAC-SHA256,但它们不是同一种 Header:
| 服务商 | 签名输入 | 输出与 Header |
|---|---|---|
| GitHub | 原始 Body | Hex,X-Hub-Signature-256: sha256=... |
| Shopify | 原始 Body | Base64,X-Shopify-Hmac-SHA256 |
| Stripe | timestamp.raw_body | Stripe-Signature: t=...,v1=... |
| Slack | v0:timestamp:raw_body | X-Slack-Signature: v0=... |
使用 Webhook 签名生成与验签工具 时,先选择真实服务商,不要把 GitHub 的 Hex 规则套到 Shopify,也不要只截取 Stripe Header 的 v1 后忽略 t。页面内置 GitHub 和 Slack 的公开测试向量,可以先证明当前浏览器算法与官方结果一致,再换成自己的脱敏请求。
如果接入的是自有系统,可以用 HMAC 签名与验签工具 明确约定签名输入、密钥字节编码、SHA 算法和 Hex/Base64 输出。协议文档应写到“哪些字节、怎样拼接”,只写一句“HMAC-SHA256”远远不够。
在框架改写 Body 之前读取原文#
不同框架拿 raw body 的位置不同。以 Web Fetch API 风格的请求为例,应先读取正文:
const rawBody = await request.text();
const signature = request.headers.get("x-hub-signature-256");
// 先验签,成功后再 JSON.parse(rawBody)如果中间件已经调用 request.json(),后面再把对象 stringify 回去,原始格式可能已经丢失。Express、NestJS、Fastify、Laravel、Django 等框架也各有自己的 Body Parser 时机,应按所用版本的官方说明保留原始 Buffer。
代理、网关和无服务器平台通常不会故意修改请求体,但错误的字符集转换、表单解析、压缩解码或自定义日志中间件可能改变内容。可以用 HTTP Header 解析与安全诊断工具 对照 Content-Type、Content-Encoding 和服务商签名 Header,确认应用实际读取的是哪一种表示。
不要为了“修好验签”先对 JSON 做格式化。JSON 格式化工具 适合在验签通过后查看内容;它生成的漂亮 JSON 不应再拿去和原签名比较。
检查 Secret 和编码#
Secret 输入框里看起来相同,不代表使用的字节相同。常见错误包括:
- 把环境变量末尾的换行一起读进密钥。
- 把 Base64 字符串先解码,但服务商要求直接按 UTF-8 使用。
- 复制时带入引号或前后空格。
- Stripe 使用了 API Secret Key,而不是 endpoint 对应的
whsec_...Signing Secret。 - Shopify 使用 Access Token,而不是应用 Client Secret。
- Slack 仍在使用已经弃用的 Verification Token。
不要在错误日志里打印完整 Secret。可以记录环境变量是否存在、UTF-8 字节数和配置版本,但真实值只应保存在受控的 Secret 管理环境。
签名匹配不等于请求仍然有效#
Stripe 和 Slack 把时间戳纳入签名,用于限制重放。工具会把结果分成两项:签名字节是否匹配,时间戳是否位于允许窗口。官方历史样例可能显示“签名正确,但时间已过期”,这恰好说明两种检查没有混在一起。
时间窗口通常只允许几分钟。服务器应使用可靠时钟同步,而不是把容差改成几小时。若时间戳很新但仍被判过期,先检查代码把 Unix 秒误当成毫秒,或容器时钟是否异常。
GitHub 与部分自定义 Webhook 不一定在签名串里带时间戳,这时要依赖服务商提供的 delivery ID、事件 ID 或业务对象版本来处理重复投递。Webhook 天生会重试,收到两次同样事件并不代表攻击。
验签通过后还要做幂等#
合法请求也可能被发送多次:上一次响应超时、网络断开、服务商重试或你的队列重复消费,都可能造成重复扣款、重复发货和重复通知。
生产处理应按事件 ID 或业务唯一键建立幂等记录:
- 验证签名和时间窗口。
- 检查事件 ID 是否已经处理。
- 以数据库唯一约束或事务抢占处理权。
- 执行业务并记录结果。
- 对重复事件返回成功,但不再次执行副作用。
还要检查事件类型、账号或店铺归属、对象当前状态和允许的状态迁移。签名证明请求来自持有 Secret 的一方,不代表所有事件都应该由当前租户处理。
用已知请求做一次完整复现#
排错时保存一条脱敏的 raw body、相关 Header 和收到时间,然后固定所有输入反复计算。也可以把请求整理成 cURL,再用 cURL 转 Fetch / HTTP 请求转换器 生成本地复现代码。注意 cURL 用于重放测试环境请求,不能把生产 Token 或 Cookie 发到公开工单。
最后逐项确认:
- 签名 Header 名称与服务商一致。
- raw body 在解析和格式化之前读取。
- Secret 的种类、环境和字节编码正确。
- 时间戳参与了正确的拼接,秒与毫秒没有混用。
- Hex、Base64、Base64URL 和前缀没有混淆。
- 比较使用官方 SDK或 timing-safe 方法。
- 事件通过签名后仍检查归属、类型和幂等。
Webhook 验签不是把两串十六进制硬凑到相同。把原始字节、服务商协议、时间窗口和重复投递分开检查,失败原因通常会很快落到一个具体步骤上。