Webhook 验签最折磨人的情况,是 Secret 看起来没错,Header 也确实收到了,自己算出的摘要却始终差一截。继续换库通常没有用,因为多数问题不在 SHA-256,而在“究竟对哪一串字节签名”。

Webhook 的 JSON 只要被解析过,就不一定还是服务商发送的原文。下面两个 Body 表达的是同一件事,签名却完全不同:

json已剪下 ✓
{"id":123,"paid":true}
json已剪下 ✓
{
  "paid": true,
  "id": 123
}

空格、换行、字段顺序和末尾换行都属于字节的一部分。正确顺序是先读取并保留 raw body,完成签名验证以后,再做 JSON 解析和业务处理。

先确认服务商的签名协议#

Stripe、GitHub、Shopify 和 Slack 都常使用 HMAC-SHA256,但它们不是同一种 Header:

服务商签名输入输出与 Header
GitHub原始 BodyHex,X-Hub-Signature-256: sha256=...
Shopify原始 BodyBase64,X-Shopify-Hmac-SHA256
Stripetimestamp.raw_bodyStripe-Signature: t=...,v1=...
Slackv0:timestamp:raw_bodyX-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 风格的请求为例,应先读取正文:

ts已剪下 ✓
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 或业务唯一键建立幂等记录:

  1. 验证签名和时间窗口。
  2. 检查事件 ID 是否已经处理。
  3. 以数据库唯一约束或事务抢占处理权。
  4. 执行业务并记录结果。
  5. 对重复事件返回成功,但不再次执行副作用。

还要检查事件类型、账号或店铺归属、对象当前状态和允许的状态迁移。签名证明请求来自持有 Secret 的一方,不代表所有事件都应该由当前租户处理。

用已知请求做一次完整复现#

排错时保存一条脱敏的 raw body、相关 Header 和收到时间,然后固定所有输入反复计算。也可以把请求整理成 cURL,再用 cURL 转 Fetch / HTTP 请求转换器 生成本地复现代码。注意 cURL 用于重放测试环境请求,不能把生产 Token 或 Cookie 发到公开工单。

最后逐项确认:

  • 签名 Header 名称与服务商一致。
  • raw body 在解析和格式化之前读取。
  • Secret 的种类、环境和字节编码正确。
  • 时间戳参与了正确的拼接,秒与毫秒没有混用。
  • Hex、Base64、Base64URL 和前缀没有混淆。
  • 比较使用官方 SDK或 timing-safe 方法。
  • 事件通过签名后仍检查归属、类型和幂等。

Webhook 验签不是把两串十六进制硬凑到相同。把原始字节、服务商协议、时间窗口和重复投递分开检查,失败原因通常会很快落到一个具体步骤上。