Webhook 验签失败或重复处理怎么办?原始请求体、时间窗口与幂等排查
从原始请求体、HMAC 拼接、时间戳容差和重放保护四层定位 API 回调问题,配本机模拟代码,区分签名通过与业务只执行一次,避免把 2xx 当成处理完成。
Webhook 已经返回 2xx,却在接收端报“验签失败”;或者重试后同一笔事件被业务处理两次,这两种现象经常被混在一起。验签只回答“这组原始字节是否由持有密钥的一方生成”,不能代替重放防护、幂等处理或业务状态校验。
本文用一个明确标注的 HMAC-SHA256 示例说明排查路径:签名覆盖 Unix 时间戳、点号和原始请求体,签名放在十六进制请求头中。这个格式只是教程约定,不是任何服务商的通用协议;接入真实平台时必须以其官方文档为准。示例只使用本机模拟请求,不调用真实付费 API。
1. 先把“请求到了”与“请求可信”分开
收到 HTTP 请求、通过 TLS、返回 2xx、验签成功和业务落库是五个不同阶段。网关可能已经把请求转发到应用,但应用仍应在解析 JSON 前核对签名。验签失败时不要把错误正文、密钥或完整请求体写入日志;记录请求编号、状态、时间窗口结果和脱敏摘要即可。
| 阶段 | 要回答的问题 | 失败时先查什么 |
|---|---|---|
| 传输 | HTTP 消息是否完整到达 | 状态、超时、代理和长度 |
| 取原文 | 参与签名的字节是否保持不变 | 压缩、字符集、换行和中间件 |
| 验签 | 算法、密钥、拼接顺序是否一致 | 官方协议、版本和密钥范围 |
| 防重放 | 时间戳和事件是否已处理 | 时钟、nonce、事件 ID 和缓存 |
| 业务处理 | 事件是否只产生一次效果 | 数据库唯一约束和状态机 |
不要因为发送方看到 200 就认为业务一定完成。接收端可以先验签并持久化事件,再异步执行业务;这时 2xx 只说明接收阶段的结果,不能证明后续任务成功。
2. 验签必须使用原始请求体
最常见的错误是先把 JSON 解析成对象,再重新序列化后验签。空格、键顺序、转义、Unicode 规范化和末尾换行任何一个变化,都会让字节串不同。压缩或网关重写也可能发生在验签前后;必须确认签名覆盖的是哪一层表示。
正确顺序通常是:
- 在框架第一次解析正文之前读取原始 bytes,并按平台限制设置请求体上限。
- 保存一份受访问控制保护的原始字节或其加密/脱敏摘要,供排查使用。
- 按官方协议拼接时间戳、版本前缀或 nonce;不要自行猜分隔符。
- 使用恒定时间比较函数比较摘要,避免普通字符串比较的时序差异。
- 验签成功后才解析 JSON,并检查事件类型、事件 ID 和版本字段。
下面的函数使用 X-Webhook-Timestamp、X-Webhook-Signature 两个教程头。签名原文是 timestamp + "." + raw_body,摘要是 HMAC-SHA256 的原始字节再转十六进制。真实平台可能使用多个签名、前缀或不同编码;请替换为它的正式规范。
import hashlib
import hmac
import time
from collections.abc import Mapping
class WebhookVerificationError(ValueError):
pass
def verify_webhook(
raw_body: bytes,
headers: Mapping[str, str],
secret: bytes,
*,
now: int | None = None,
tolerance: int = 300,
) -> int:
if not isinstance(raw_body, bytes):
raise TypeError("raw_body must be bytes")
if not isinstance(secret, bytes) or not secret:
raise ValueError("secret must be non-empty bytes")
if type(tolerance) is not int or tolerance < 0:
raise ValueError("tolerance must be a non-negative integer")
timestamp_text = headers.get("X-Webhook-Timestamp", "").strip()
signature_text = headers.get("X-Webhook-Signature", "").strip()
try:
timestamp = int(timestamp_text)
except ValueError:
raise WebhookVerificationError("INVALID_TIMESTAMP") from None
if timestamp < 0:
raise WebhookVerificationError("INVALID_TIMESTAMP")
current = int(time.time()) if now is None else now
if type(current) is not int:
raise TypeError("now must be an integer timestamp")
if abs(current - timestamp) > tolerance:
raise WebhookVerificationError("STALE_TIMESTAMP")
try:
provided = bytes.fromhex(signature_text)
except ValueError:
raise WebhookVerificationError("INVALID_SIGNATURE_FORMAT") from None
signed = str(timestamp).encode("ascii") + b"." + raw_body
expected = hmac.new(secret, signed, hashlib.sha256).digest()
if not hmac.compare_digest(provided, expected):
raise WebhookVerificationError("BAD_SIGNATURE")
return timestamp
这个函数只验证身份材料,不解析业务 JSON,也不负责保存事件。tolerance=300 表示时间戳与接收机时钟相差超过 300 秒就拒绝;生产值应按平台重试窗口、网络延迟和时钟同步情况调整,不能盲目放大。
3. 按错误阶段定位,而不是盲目换密钥
| 现象 | 优先检查 | 不要直接得出的结论 |
|---|---|---|
| 所有事件都 BAD_SIGNATURE | 密钥环境、原始体读取位置、拼接格式和十六进制/文本转换 | 发送方一定改了协议 |
| 只有带中文或换行的事件失败 | 是否先解码、规范化或重排 JSON | UTF-8 本身不支持中文 |
| 偶发 STALE_TIMESTAMP | NTP、容器时区和代理排队时间 | 事件一定是攻击 |
| 重试时验签成功但效果重复 | 事件 ID 是否落库、唯一键和事务边界 | 验签函数失效 |
| 2xx 后仍不断重试 | 发送方要求的响应时间、状态码范围和网络可达性 | 业务处理越快越好 |
| 反向代理后全部失败 | 代理是否改了正文、压缩或大小写敏感的头 | 只需把签名校验关闭 |
把一次失败拆成四份证据:脱敏后的原始字节长度和哈希、签名头解析结果、使用的密钥版本标识、接收时间与发送时间差。不要把 HMAC 密钥、Authorization、Cookie 或完整载荷放进普通日志。若还涉及客户端代理,参见 HTTPX 代理环境排查;若需要向服务商提交材料,使用 API 调用失败的最小复现模板。
4. 时间戳只能缩短重放窗口
签名正确的请求也可能是旧请求的复制品。时间窗口只能阻止过期消息,不能阻止攻击者在窗口内重复发送同一事件。因此需要同时使用:
- 事件 ID 或 nonce:在验签后、执行业务前检查是否已见过。
- 短期去重存储:用带过期时间的键记录 provider + event_id;过期时间至少覆盖发送方最大重试窗口。
- 原子写入:用数据库唯一约束或缓存的“只写一次”操作,避免两个并发请求同时通过检查。
- 时钟同步:容器和主机使用可靠的时间同步;日志同时记录 UTC 和本地展示时间。
- 有界重试:业务失败时返回符合协议的状态,让发送方重试;不要因为重复事件再次执行不可逆操作。
事件的“已收到”和“已完成”是两个状态。可以先插入 received 记录并返回 2xx,再由队列消费者把它推进到 processed、failed 或 dead_letter。消费者必须在事务中检查状态,重复投递时返回已有结果。涉及付款、充值或额度变更时,还要按请求编号与账单记录对账,参考 API 账单核对教程。
5. 在本机构造四类测试
不接真实服务也能验证接收端的边界。为同一份原始 bytes 计算正确签名,然后分别测试:
- 原文完全一致:应通过。
- 只改变 JSON 空格或末尾换行:应失败。
- 时间戳超出容差:应返回 STALE_TIMESTAMP。
- 相同事件 ID 并发两次:验签都可通过,但只有一次业务副作用。
测试时使用固定的测试密钥和人工构造的时间,不要把生产密钥写进示例仓库。除了单元测试,再增加一次真实框架的“读取原文”集成测试,确认 body parser 没有先消费或重写内容。若启用了 gzip,分别记录签名覆盖压缩前还是压缩后的表示,不能凭 Content-Encoding 猜测;相关边界可参考 gzip 响应排查。
6. 生产上线检查清单
- 从官方文档逐项抄录签名头、版本、编码、拼接顺序和密钥轮换规则。
- 明确原始 body 的读取位置与最大大小,拒绝超限请求。
- 验签使用恒定时间比较,失败响应不泄露哪一部分不匹配。
- 时间窗口、时钟同步和事件 ID 去重都有监控。
- 数据库对事件 ID 或业务幂等键设置唯一约束,并定义重复投递的返回结果。
- 记录脱敏诊断字段:事件 ID、请求 ID、版本、时间差、body 长度和哈希。
- 轮换密钥时支持旧、新版本的短暂重叠,验证后再撤销旧密钥。
- 用本机样例覆盖正文改写、重试、并发和下游失败,不发送真实付费请求。
签名验证解决的是“消息是否符合协议”,幂等解决的是“重复消息只产生一次效果”,业务状态机解决的是“效果是否完成”。三层都通过,才可以把回调视为可用。
7. 一手资料与适用范围
资料于 2026-11-08 核对。本文的 HMAC 拼接格式是教学协议,不能冒充任何平台的签名规范。
- [1] RFC 2104:HMAC:定义 HMAC 的结构与密钥使用。
- [2] Python hmac 文档:compare_digest 和 HMAC API 的行为。
- [3] Python hashlib 文档:哈希算法接口与摘要字节。
- [4] OWASP Webhook Security:时间戳、重放防护、原始请求体与幂等的通用检查项。
本文没有测试任何特定服务商的 webhook、TLS、HTTP/2、签名轮换后台或计费系统;本机测试只验证已写明的 HMAC 协议和接收端边界。