Webhook 签名详解

Webhook 签名是发送方服务附加在每次投递上的一小串字符——用只有发送方与接收方共享的密钥,对请求的原始字节计算出的 HMAC。它的存在,是为了让接收服务器在采取任何行动之前回答两个问题:这真的来自它声称的那个服务吗?body 是否原样到达?本文解释这套验证到底如何工作:HMAC 覆盖什么、为什么必须拿原始 body 而不是解析后的对象去验、为什么比较必须恒定时间、时间戳如何界定重放攻击、以及为什么每家厂商的头部格式都不一样。如果你想先看一次真实投递——头部、签名与原始 body 放在一起——可以让某个发送方把请求打到 Webhook 测试器,看看实际到达的是什么。

为什么一个秘密 URL 不算身份验证

最古老的 Webhook 防护是不可猜测的 URL:一段只有发送方服务知道的长随机路径。它感觉像密码,但行为像地址,而地址会泄露。这个 URL 会被写进访问日志、代理日志、监控面板、问题报告里的截图,以及发送方服务自身的配置页面——任何支持工程师,或者攻陷了其中任何一个系统的人,都能读到它。密码可以静态哈希、可以按用户轮换;URL 却在使用它的每个地方被原样复制,每一份副本都是一张长期有效的邀请函。

更深层的问题是端点在请求到达后拿它做什么。Webhook 处理器会把账单标记为已付、开通账户、发放许可证、删除数据。这些都是值得伪造的动作,而伪造一个请求的成本不过是攻击者从互联网任意位置发出的一次 HTTP POST。TLS 在这里帮不上忙,因为 TLS 是向连接方验证你的服务器,它对连接方是谁一言不发。签名把信任从“知道敲哪个门”转移到“知道一个从不出现在请求本身中的密钥”,这是类别上更强的主张。

HMAC 签名到底证明了什么

发送方与接收方共享一个密钥,在端点注册时生成。对每次投递,发送方对一组确定的字节计算 HMAC——一种带密钥的哈希:通常是原始请求 body,往往在前面加上时间戳,有时还带上投递 ID。结果放在一个头部里传输。接收方用自己那份密钥,对收到的字节重新计算 HMAC,并比较两个值。匹配同时证明两件事:发送方持有密钥,并且被覆盖的字节与发送方签名的字节逐比特一致。把金额字段改动一个字符,重新计算出的值就会完全不同。

有两条局限值得说清楚。第一,签名不是加密:body 在网络上对任何能看到它的人都是可读的——让它保持私密是 TLS 的工作——签名只为来源与完整性背书。第二,对 body 做一个普通哈希什么也证明不了,因为任何人都能算哈希。是密钥把哈希变成了消息认证码。本站的哈希生成器算的是裸哈希,作校验和很好用——但一个人人可重算的校验和,认证不了任何人。

逐字节验证原始 body

“真实投递却验证失败”最常见的原因不是攻击者,而是接收方自己的框架。签名是对 body 按传输时的原样计算的:这个键顺序、这些空白、这些转义字符、这种数字格式。大多数 Web 框架会在你的处理器运行之前体贴地把 JSON body 解析成对象,而如果你把那个对象重新序列化去验证,得到的是相同的数据、不同的字节:键被重排、空格被添加、1e3 被渲染成 1000、unicode 被转义或取消转义。语义上相同,密码学上不同——于是一次完全真实的投递验证失败。Stripe 的文档把规则说得很直白:签名验证需要原始 body,框架不得对它做任何加工。

操作上的推论是顺序问题:在任何东西解析它之前,先捕获原始请求 body,用这些字节验证签名,然后才解析和处理。会改写 body 的代理与中间件——重新编码、规范化、美化排版——和解析器一样能破坏签名,这就是为什么调试要从“按请求到达时的原样去看它”开始。接收工具给你的正是这个视图:一次投递落到 Webhook 测试器 里时,你检查的是未被触碰的请求,头部与 body 都按传输时的样子呈现,可以拿它诚实地核对签名。

恒定时间比较,以及为什么时钟是签名的一部分

比较两个 HMAC 值看似是最 trivial 的一步,却藏着一个微妙的泄露。普通字符串比较在第一个不匹配的字符处停下,所以猜测错得越早返回越快、恰好对上的前缀越长返回越慢。经过大量请求,这种时间差能让攻击者在从不知道密钥的情况下,一次一个字符地“量”出一个有效签名。恒定时间比较总是检查完整长度,无论两个值在第一个字符还是最后一个字符不同,耗时都相同。各平台文档对此说得很不客气——比如 GitHub 的文档明确警告不要用普通的相等运算符,并指向 crypto.timingSafeEqual 这类恒定时间函数。用库函数;不要自己写比较循环。

新鲜性是第三个性质,而签名本身给不了你:一个被截获的真实投递原样重放,带着完全有效的签名。这就是为什么许多方案把时间戳折进被签名的字节里,以及为什么接收方会拒绝时间戳落在容差窗口之外的投递——按 Stripe 的文档,其方案默认是五分钟。这个窗口是一种刻意的折中:宽到足以吸收时钟偏移与缓慢的队列,窄到让被截获的请求很快过期。在窗口之内,防御落到幂等性上:记录你处理过的每一个投递或事件 ID,丢弃重复——反正你也需要它,因为诚实的发送方会重试,而重试就是带着善意的重放。

为什么每家厂商的头部长得都不一样

机制是标准的;包装从未统一。Stripe 发送形如 t=<timestamp>,v1=<signature> 的 Stripe-Signature 头部,并且可以同时包含多个 v1 值。GitHub 发送 X-Hub-Signature-256,其值是带算法前缀的字符串 sha256=<hex digest>——没有时间戳,所以 GitHub 的投递没有内置过期,防重放完全靠投递 ID 去重。Standard Webhooks 规范是对这个问题的统一尝试:投递 ID、时间戳与签名各占一个头部,签名对三者加上 body 一起计算,这样新厂商就不必再发明互不兼容的变体。这些差异是历史偶然,不是不同的安全模型——在每种方案里,不变量都相同:一个共享密钥、一组确定的被覆盖字节、一次恒定时间比较。

Stripe 格式里的多个 v1 值,是为每个接收方迟早要面对的操作准备的:轮换密钥。天真地做——改密钥、部署、祈祷——轮换会打断所有在途投递,因为用旧密钥签名的重试与排队投递会突然验证失败。可行的模式是并行窗口:在重叠期,新旧两个密钥下的签名都被接受,你确认投递能在新密钥下验过,然后才退役旧密钥。同样的窗口模式出现在 JWT 的世界里——密钥 ID 与重叠密钥——团队跳过它时的故障模式也一模一样,正如JWT 常见错误一文所述。一个无法在不停机的情况下轮换的密钥,即使到了该轮换的时候也不会被轮换——包括它泄露的那一天——这就是为什么这个不起眼的并行窗口是协议的一部分,而不是事后补丁。

来源与延伸阅读

常见问题

我确知是真实的投递,验证却失败了。先从哪里查?

先查 body。按顺序确认:你验证的是到达时的原始字节,还是解析后再重新序列化的版本?你用的密钥是否属于这个端点、这种模式——测试与生产端点的密钥不同,每个注册的端点也有自己的密钥?发送方与你的处理器之间是否有东西——代理、框架中间件——在改写 body?捕获一次未被触碰的投递,比如在 Webhook 测试器 里,并用那些确切的字节验证,一步就能区分“发送方签得怪”与“我的技术栈改了字节”。

我们已经用了 HTTPS。签名不是多余的吗?

不多余——两者验证的是不同方向。TLS 向发送方证明它到达了你真正的服务器,并加密传输中的 body。它对连接方是谁没有任何要求:互联网上任何人都能与你的端点建立一个完全有效的 TLS 连接,并 POST 一个伪造的“支付成功”body。签名才是验证发送方与内容的那部分。TLS 让投递保持私密;签名让投递值得信任。

Webhook 密钥应该多久轮换一次?

一旦怀疑泄露,立即轮换——它躺在面板、环境文件与 CI 变量里,所以把暴露视为时间问题。常态下,按你的团队轮换同类凭据的节奏即可,前提是轮换足够便宜:有了双接受窗口,它就是一次配置变更,而不是一次事故。如果某个厂商的方案让轮换变得痛苦,在选择集成时把这一点计入考量,并优先选 Standard Webhooks 这类公开发布的方案——能否轮换是协议的安全属性,不是锦上添花。