Webhook 签名验证

收到回调却提示签名错误时,用这个页面对一遍:算法、密钥、编码格式、签名的到底是哪段字节,逐个排除。

密钥只在本机参与 HMAC 计算,页面不会发起任何请求,也不会把密钥写到地址栏或存储里。

为什么必须对原始字节签名

HMAC 是对一段确定的字节序列做运算,任何一个字节不同,结果就完全不同。平台在发请求时对「它准备发出的那串字节」算签名,而 HTTP 请求体在网络里传的是什么字节,就决定了签名是什么。你在服务端如果用 JSON.parse 再 JSON.stringify 把请求体重新序列化一遍,键的顺序可能被改成字母序、多余的空格被删掉、1.0 变成 1、中文从转义形式变回原字符,这些改动都会让 HMAC 对不上。正确做法是把接收到的原始 body 按字节保存下来再算签名,框架里通常都有「原始请求体」这个方法。

编码格式也容易踩坑。同一个 32 字节摘要可以用十六进制(64 个字符)或 Base64(44 个字符,带一个等号)表示,两者能互相转换。GitHub 用十六进制并加了 sha256= 前缀,Stripe 用十六进制放在 v1= 里,还有一些平台用 Base64。本页在比较之前会先把两边都换算成同一串十六进制,所以「签名值是对的但编码写法不同」这种情况不会误判成不匹配。

比较签名要用定长比较,不能用 == 或字符串比较。普通比较会在第一个不同的字符处就返回,攻击者可以反复发请求,根据响应耗时的细微差别一个字符一个字符地猜出正确签名,这就是时序攻击。本页用的是逐字节异或累加的做法,无论哪里不同都会走完全部字符。

Stripe 的签名头里带时间戳,服务端应该检查这个时间戳距现在是否超过几百秒,超了就当作重放请求拒绝,否则攻击者只要拿到一次合法的签名就能无限次重放。另外,签名校验通过只说明「请求来自知道密钥的人」,还要自己检查事件类型、金额、订单号等业务字段,别把校验签名当成完整的安全方案。

本页的 HMAC 走浏览器自带的 Web Crypto,密钥不进网络、不写存储,刷新页面就没了。不过还是建议用测试环境的密钥来排查问题,别把生产密钥贴到任何网页里。