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 签名格式速查
| Provider | Header | 编码 | 签名载荷 |
|---|---|---|---|
| Stripe | Stripe-Signature: t=[ts],v1=[hex] | hex | [ts].[raw body] |
| GitHub | X-Hub-Signature-256: sha256=[hex] | hex | raw body |
| Shopify | X-Shopify-Hmac-SHA256: [base64] | base64 | raw body |
| Slack | X-Slack-Signature: v0=[hex] | hex | v0:[ts]:[raw body] |
| Generic | 自定义 | hex | raw 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 安全工具栈。