Webhook 签名 验证数据负载并阻止伪造请求
了解HMAC-SHA256 Webhook签名的工作原理,如何在Python、Node.js和PHP中实现恒定时间验证,以及在生产环境中导致静默失败的常见错误。
任何人都可以向您的Webhook端点发送POST请求。如果没有签名验证,您的服务器无法确定该请求是否确实来自Stripe、GitHub或您的基础设施,或者来自攻击者对合法事件的重放。
Webhook签名验证解决了这个问题。它是支付处理器、版本控制平台和电子商务系统在处理接收到的数据之前证明其真实性的方法。本文将详细介绍HMAC-SHA256签名的工作原理、逐步验证模式以及在生产环境中导致失败的细微错误。
未认证的Webhook存在安全风险
Webhook端点只是暴露在互联网上的一个HTTP处理器。如果没有验证,任何到达它的请求都会被处理。两种攻击尤为突出:
- 伪造 —— 攻击者构造一个看起来像合法事件(如支付成功、订阅续订)的虚假负载,并在您的系统中触发操作,而实际上并未发生任何真实交易。
- 重放攻击 —— 一个合法请求在传输过程中被截获并稍后重新提交。如果您的端点是幂等的但未受保护,相同的事件将被多次触发。
这两种攻击都可以通过共享密钥和加密签名来防止。发送方对负载进行签名,您在处理任何内容之前必须验证签名。
HMAC-SHA256签名的工作原理
HMAC(基于哈希的消息认证码)接受两个输入:一个 密钥 和一个 消息。它将这两个输入通过哈希函数(大多数Webhook实现中为SHA-256)处理,并输出一个固定长度的签名。
关键特性是:相同的密钥和消息始终产生相同的签名,而消息中哪怕只改变一个字节,也会产生完全不同的输出。没有密钥的人无法生成有效的签名,即使他们可以看到完整的负载内容。
在实际操作中,流程如下:
- 您将Webhook注册到服务(例如Stripe)。服务会为您提供一个 签名密钥.
- 当服务发送事件时,它会计算
HMAC-SHA256(secret, payload)并将结果包含在请求头中。 - 您的服务器接收到请求后,使用存储的密钥和原始请求体重新计算相同的HMAC,然后将两个签名进行比较。
- 如果它们匹配,则请求是真实的;如果不匹配,则拒绝该请求。
逐步验证模式
以下是一个Python实现,与所有主要服务所期望的行为一致:
import hmac
import hashlib
def verify_signature(payload: bytes, secret: str, received_sig: str) -> bool:
expected = hmac.new(
key=secret.encode("utf-8"),
msg=payload,
digestmod=hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, received_sig)
除了HMAC调用本身,这里有两个关键点。首先: payload 是 bytes,而不是解码后的字符串——您必须将 原始请求体 完全按从网络接收到的方式传递。其次:比较使用 hmac.compare_digest 而不是 ==。这是有意为之的。
为什么常量时间比较不是可选的
大多数语言中的字符串比较会短路:一旦发现字符不匹配,就会立即返回 false 攻击者可以通过测量响应时间来利用这一点——发送成千上万带有不同签名的请求,并利用时间差异逐个猜测正确值的字符。这是一种 时间攻击.
hmac.compare_digest 在Python中, hash_equals 在PHP中,和 crypto.timingSafeEqual 在Node.js中,无论字符串在何处不同,所有比较操作所需的时间都相同。每次比较签名时都应使用它们,没有例外。
实际的请求头格式:Stripe、GitHub、Shopify
每个服务都有略微不同的请求头名称和签名编码方式。以下是三个最常见集成所需解析的内容。
Stripe
Stripe发送一个 Stripe-Signature 头,格式如下:
Stripe-Signature: t=1492774577,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a05bd539e6d5b9d2a2d2fbe
这 t 字段是事件发送时的Unix时间戳。该 v1 值是 timestamp + "." + raw_body的HMAC-SHA256。在手动实现时:
- 解析头以提取
t且v1. - 计算
HMAC-SHA256(secret, t + "." + raw_body). - 与
v1使用常量时间相等性进行比较。 - 如果
t与当前时间相差超过300秒(5分钟),则拒绝请求。
GitHub
GitHub使用 X-Hub-Signature-256 头中:
X-Hub-Signature-256: sha256=6ffbb59b2300aae63f272406069a9788598b770f698a48021f99b32f8de06bb3
去除 sha256= 前缀,然后将剩余部分与您对原始体计算的HMAC进行比较。GitHub未在签名内容中嵌入时间戳,因此如果重放保护对您的用例很重要,需单独实现事件ID去重。
Shopify
Shopify使用 X-Shopify-Hmac-Sha256 ,其摘要以Base64编码而非十六进制:
X-Shopify-Hmac-Sha256: b6LPiZidmXnJQf0Ff/p7MZQPIBPN9TqAqLMgAfn2YLQ=
将您计算出的HMAC和头值都从Base64解码为原始字节,然后使用常量时间函数进行比较。直接比较Base64字符串可能会引入微妙的编码规范化错误。
最常见的错误:原始体与解析后的JSON
这正是生产环境中大多数Webhook签名验证失败的地方。当您的框架在处理器运行前自动将请求体解析为字典或对象时,原始字节就消失了。HMAC是基于这些原始字节计算的——而不是基于您的JSON库在重新编码解析结构时生成的内容。
即使数据在语义上相同, {"amount": 100} 且 {"amount":100} (冒号后没有空格) 会产生不同的HMAC值。字段顺序差异、Unicode规范化和浮点数精度都可能在不明显的情况下破坏验证。
解决方法: 在任何解析操作之前缓冲原始请求体,并将这些字节传递给您的验证函数。在Express.js中,使用 express.raw() 中间件而不是 express.json():
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body; // Buffer — not a parsed object
const sig = req.headers['stripe-signature'];
// Verify rawBody against sig before JSON.parse()
});
在Django中,使用 request.body (字节)。在Laravel中使用 $request->getContent()。该模式是一致的:直接从请求中获取原始字节,永远不要重新序列化解析后的表示形式。
使用时间戳进行重放保护
有效的签名只能证明数据在传输过程中未被篡改。它无法防止攻击者捕获一个合法请求并数小时后重新提交——因为签名仍然会正确验证,因为内容没有改变。
Stripe通过在签名负载中包含Unix时间戳,并推荐一个5分钟的容差窗口来解决这个问题。在提取 t 后,从 Stripe-Signature 头中,拒绝时间戳超出该窗口范围的请求:
import time
def is_within_tolerance(timestamp: int, tolerance_seconds: int = 300) -> bool:
return abs(time.time() - timestamp) <= tolerance_seconds
# In your handler:
if not is_within_tolerance(stripe_timestamp):
return HttpResponse(status=403) # Reject stale request
对于GitHub和Shopify等不将时间戳嵌入签名方案的服务,通过存储已处理的事件ID(在负载中提供)来实现自己的重放保护。一个具有匹配处理窗口TTL的短生命周期缓存或Redis集合效果良好。
无需编写代码即可验证签名
在调试Webhook集成——或审核已接收的负载时—— Webhook Signature Validator 允许您粘贴一个负载、密钥和接收到的签名,以立即检查验证结果,而无需启动本地环境。
为了生成用于测试您端点的自定义负载的HMAC签名,请使用 HMAC 生成器。它为SHA-256、SHA-512和多种其他摘要算法生成十六进制和Base64输出——可用于构建覆盖各服务格式的测试用例。
快速参考
- HMAC-SHA256配合共享密钥是Stripe、GitHub、Shopify及其他大多数服务的标准签名方案。
- 始终使用常量时间比较(
hmac.compare_digest,hash_equals,crypto.timingSafeEqual)——而不是==. - 将原始请求体字节传递给您的HMAC函数,永远不要传递重新序列化的JSON对象。
- 检查包含时间戳的服务;Stripe的容差窗口为5分钟。
- 对于不包含基于时间戳的重放保护的服务,通过事件ID进行去重。
- 每个服务的请求头名称和编码方式(十六进制与Base64)不同——始终查阅集成文档。
