Stripe 报 No signatures found matching the expected signature for payload。GitHub 报 We could not verify your signature。Shopify 报 HMAC validation failed。三个看起来不同的报错,根因通常只有 7 个,90% 的情况是第 1 个。

本文按出现频率从高到低排列,每个都给你可复制的 fix + 本地复现的 1 行命令。

30 秒总览

  • webhook 签名 = HMAC-SHA256(secret, raw_body),公式没变,变的是 header 格式和编码细节
  • 7 个失败原因按频率:raw body 被 parse / 错误 secret / 编码错(hex vs base64)/ timestamp 容差 / 密钥 commit 进代码 / 用 === 比较签名 / 调试时 secret 进日志
  • 每个 provider 用不同 header 格式和编码:GitHub 用 sha256= 加 hex;Shopify 用裸 base64;Slack 用 v0= 加 hex;Stripe 用 t=…,v1= 加 hex (方括号代表占位符)
  • Piick Webhook 签名验证器 在浏览器本地复现任何签名前后是否一致,5 个 provider 都覆盖

7 个失败原因,从最常踩到最隐蔽

原因 1:验签的不是 raw body,而是 parsed JSON

这是 50% 的 webhook 签名失败的根因。所有 provider 都签 raw bytes,你却签了 JSON.stringify(req.body)。Express / Flask / Next.js 默认会 parse body 然后重新 stringify,key 顺序或空格变了,签名就对不上。

正解:

  • Express 用 express.raw() 取代 express.json(),handler 里直接拿 req.body (Buffer) 验签
  • Flask 用 request.get_data(as_text=False) 而不是 request.json
  • Next.js API route 加 export const config = api 加 bodyParser: false 的配置项

调试:打印 req.body 的前 30 个字符和 Content-Length,对比原始 POST 数据是否一致。

原因 2:secret 用错了

本地测试用 Stripe CLI 的临时 secret (stripe listen 打印的),部署到线上又用了 dashboard 那个。同一个 endpoint 在两个环境的 secret 是不同的字符串。

正解:每个 endpoint 在每个环境独立配置,env 变量从 secret manager 读,不在代码里硬编码。

调试:在 dashboard / Stripe CLI / 代码 env 三处核对同一个 secret 值,确认完全一致。

原因 3:编码搞混

90% 的 provider 用 hex,但 Shopify 唯一用 base64。如果你给 Shopify 发了 hex,签名永远对不上。

正解:严格按 provider spec,不要手动切 hex / base64。用 Piick Webhook 签名验证器 的 provider 下拉直接选对。

调试:对比生成的 hex 长度 (64 字符) 和 base64 长度 (44 字符),长度对了格式基本不会错。

原因 4:timestamp 容差没设或设错 (Stripe 专属)

webhook 5 分钟前发的,你的服务器还是拒。Stripe 在 t=… 字段里带时间戳,SDK 默认只接受 ±5 分钟的请求。

正解:Stripe.webhooks.constructEvent(payload, sig, secret, tolerance=300),300 秒够生产用,但别设太大 (防 replay 攻击的窗口)。

调试:打印 now - t 的差值,如果大于 300 一定是服务器时钟漂移,同步 NTP。

原因 5:密钥存在代码里 commit 上去了

secret 泄露,攻击者用真 secret 签发伪造 webhook。

正解:立即 rotate secret (env 变量从 secret manager 读),用 git log -p 加上 grep -i whsec 找历史泄露。

调试:把 secret 当密码对待,绝不让它进版本控制。

原因 6:用 === 比较签名

功能上能用,但理论上可被远端 timing 攻击。攻击者连续发请求测量响应时间差,逐步破解签名。Node.js 的 crypto.timingSafeEqual / Python 的 hmac.compare_digest 是 constant-time 比较,破解不了。

正解:

  • Node 用 crypto.timingSafeEqual(Buffer.from(sig1), Buffer.from(sig2))
  • Python 用 hmac.compare_digest(sig1, sig2)

调试:grep 代码里的 === 和 ==,凡是用来比较签名的都换成 timing-safe 版本。

原因 7:本地调试 secret 跟生产一致,但调试时打印过 secret 进日志

本地能 work,部署后所有 webhook 全 fail。原因是你在调试时把 secret 写进了日志 (或 commit),rotate 后 webhook 重新签名用新 secret,但生产环境 env 变量没更新。

正解:用 console.log(sig verify, matched: true) 只打印匹配状态,不打印 secret 和签名本身。

调试:git log -p 搜索 secret 关键字,确认历史 commit 没泄露过。

怎么本地复现任何签名

  • Piick Webhook 签名验证器 的 Compute tab 把 secret 加 raw body 加 provider 填进去,生成的 signature header 直接粘到你的 webhook 配置
  • 用 Verify tab 把收到的 header 粘进去,看是否匹配,0 错误立即反馈,不用打 console.log 重启服务
  • 这工具纯本地 (浏览器 Web Crypto API),不传 payload / secret 出去,可以放心用真 secret 调试

5 个 provider 签名格式速查

ProviderHeader编码签名载荷
StripeStripe-Signature: t=[ts],v1=[hex]hex[ts].[raw body]
GitHubX-Hub-Signature-256: sha256=[hex]hexraw body
ShopifyX-Shopify-Hmac-SHA256: [base64]base64raw body
SlackX-Slack-Signature: v0=[hex]hexv0:[ts]:[raw body]
Generic自定义hexraw body
  • 唯一用 base64 的是 Shopify,其他都用 hex
  • 唯一带 timestamp 的是 Stripe 和 Slack
  • 唯一带前缀的是 GitHub (sha256=) 和 Slack (v0=)
  • 想看完整版可以直接用 webhook-signature-validator 的 provider 下拉,每种都内置示例 payload

推荐做法

  • 用 express.raw() / request.get_data() 处理 webhook,绝不用 JSON.stringify(parsedBody)
  • 严格按 provider 编码,Stripe / GitHub / Slack = hex,Shopify = base64
  • 比较签名用 timingSafeEqual / compare_digest,不用 ===
  • Stripe 设 5 分钟 timestamp 容差,防 replay 攻击
  • secret 永远走环境变量,不进代码,也不进日志

想自己验证签名?用 Piick Webhook 签名验证器 在浏览器本地算签名和验证签名,5 个 provider 覆盖,数据全在浏览器里。配合 JWT 解码器 一起用,完整覆盖你的 auth 和 webhook 安全工具栈。