安全接收 Webhook 至少需要四道独立门禁:在任何解析前保留原始请求体,用共享密钥验证消息认证码;限制签名时间窗并记录 delivery/event ID,阻止旧请求重放;用业务幂等键保证重复投递无害;最后才把事件交给异步处理。只验证一个 HMAC 字符串,不能证明请求新鲜、只执行一次,也不能替代授权和输入校验。
Webhook 验证究竟在证明什么?
Webhook 是外部系统主动调用你的公开端点。一个完整的接收器要分别回答四个问题:
- 完整性与来源持有证明:请求体是否被修改,发送方是否持有约定密钥?
- 新鲜度:这是不是很久以前捕获后再次发送的有效请求?
- 幂等性:提供方重试或攻击者重放时,业务副作用会不会重复?
- 授权与语义:这个已签名事件是否允许触发当前资源上的动作?
HMAC 主要回答第一个问题。RFC 2104定义了基于密码学 hash 的消息认证机制;它不自动携带时间、事件身份或业务权限。即使签名完全正确,同一个请求也可能被无限次重放,或包含应用不应执行的语义。
因此,签名通过不是“Webhook 已处理”的同义词。建议把状态拆为 RECEIVED、AUTHENTICATED、FRESH、DEDUPED、ACCEPTED、APPLIED,每一步有独立失败原因和观测指标。
为什么必须验证原始请求字节?
提供方通常对实际发送的 bytes 计算 HMAC。你的框架如果先把 JSON 解析成对象,再重新序列化,空格、键顺序、Unicode 转义和换行都可能改变,合法签名就会失败。更危险的是,中间件可能在你验证前解压、转换编码或消费 body。
GitHub 官方验证指南要求使用 webhook secret 对 payload 计算 SHA-256 HMAC,并与 X-Hub-Signature-256 比较。核心顺序应该是:
receive bounded raw bytes
-> parse signature metadata
-> compute MAC over the exact required signing input
-> constant-time compare
-> check time and delivery identity
-> parse JSON
-> validate schema and authorization
-> enqueue or apply idempotently
下面的 FastAPI 风格代码只是工程示例,不对应某个提供方的完整 header 格式:
import hashlib
import hmac
async def verify_webhook(request, secret: bytes):
raw = await request.body()
if len(raw) > MAX_WEBHOOK_BYTES:
raise PayloadTooLarge()
supplied = parse_signature(request.headers["X-Signature"])
expected = hmac.new(secret, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(supplied, expected):
raise InvalidSignature()
return raw
Python 官方 hmac 文档建议验证时使用 compare_digest(),以减少普通 == 可能带来的 timing attack 风险。比较前还要严格限制算法、编码与长度,不能让请求方选择 none、弱算法或任意 digest。
接收端必须限制请求体大小,并为读取设置超时。否则攻击者可以在签名验证之前就用超大 body 或慢速连接消耗内存与 worker。安全门禁的第一步其实是资源边界,不是密码学。
时间戳签名怎样阻止重放?
如果提供方把时间戳纳入签名输入,接收端可以同时验证签名和允许时间窗。例如:
signed_payload = timestamp + "." + raw_body
校验步骤是:解析时间戳;拒绝格式错误和超出范围的值;用时间戳与原始 body 重算签名;常量时间比较;最后记录事件身份。顺序不能只查时间戳而不把它签进去,否则攻击者可以修改时间戳,为旧 body 伪造“新鲜”外观。
允许窗口没有通用最佳值。太短会误伤队列延迟、网络抖动和时钟漂移;太长会扩大重放窗口。应根据提供方重试策略、跨区域延迟和自己的 NTP 监控设定,并把“本地时钟异常”与“请求过期”区分告警。
时间窗仍不能阻止窗口内重放。必须再使用提供方稳定的 delivery ID 或 event ID 去重。若提供方没有可靠 ID,可以对已验证的关键字段和签名生成接收端 fingerprint,但要清楚:fingerprint 的碰撞域、字段规范化和相同业务事件的合法重发都需要定义。
去重记录与业务幂等有什么区别?
去重记录回答“这个 delivery 是否见过”;业务幂等回答“这项动作是否已经完成”。两者相关但不能合并成一个布尔值。
| 状态 | 说明 | 重试策略 |
|---|---|---|
RECEIVED |
已持久化经过验证的 envelope | worker 可继续 |
PROCESSING |
某个 worker 持有 lease | 超时后可接管 |
APPLIED |
业务副作用确认完成 | 重复请求直接返回成功 |
FAILED_RETRYABLE |
临时下游失败 | 指数退避,保留同一 operation ID |
FAILED_FINAL |
schema、权限或永久业务错误 | 告警/人工处理,不盲目重试 |
不要在业务副作用前把 delivery 标成 APPLIED,否则中途崩溃会导致事件永久跳过。也不要在处理结束后才首次保存 delivery ID,否则两个并发请求可能同时执行。常见做法是先以唯一键插入 inbox 记录,再由 worker 通过 lease/CAS 获取处理权,副作用本身仍使用业务幂等键。
例如,支付事件的 delivery ID 不是订单版本。提供方可能用新的 delivery ID 重投同一个业务事件,或者不同事件都指向同一订单。更新订单时应检查 provider event ID、对象 ID、事件类型和单调业务版本,不能只依赖 HTTP 请求身份。
这与 FastAPI、MongoDB 与 Redis 原子发布流程中的 revision CAS 类似:请求被接受、Mongo 状态提交、缓存失效和页面可见是不同阶段。Webhook 也必须区分 envelope 去重、业务提交与下游最终一致。
HTTP 应该先返回 2xx 还是处理完再返回?
对耗时或依赖外部系统的事件,推荐流程是:同步完成 body 上限、签名、时间窗、去重持久化与基本 schema 校验;确认 durable inbox 写入成功后尽快返回 2xx;异步 worker 再执行副作用。
如果在持久化前返回 2xx,接收进程崩溃会丢事件。如果等待所有业务逻辑完成才返回,提供方的短超时会触发重试,从而加剧并发与重复。具体超时和重试行为以提供方官方文档为准,不能假设所有平台相同。
可使用 FastAPI 后台任务选型指南判断队列边界。关键事件不应只依赖进程内 BackgroundTasks:进程退出后没有 durable retry,多个实例也缺少统一 lease。先持久化 inbox/outbox,再由任务队列或 workflow 消费更可靠。
建议返回策略:
- 签名无效、时间过期、body 超限:返回 4xx,不进入队列;
- envelope 已存在且已
APPLIED:返回与提供方兼容的 2xx; - envelope 已存在且处理中:通常返回 2xx,避免形成重试风暴;
- durable inbox 不可用:返回 5xx,让提供方按策略重试;
- 业务永久错误:HTTP 已经 2xx 的异步路径中进入 dead-letter 与人工处理。
密钥轮换如何避免中断?
轮换需要一个有限的双密钥窗口。接收端先加载新旧两个 secret;部署完成后在提供方切换;观察旧签名流量降到零;再删除旧 secret。每个 secret 应有内部 key ID,但不要把 secret、完整签名或原始敏感 body 写入日志。
验证多个 key 时要限制数量,并记录“哪个 key ID 成功”而非密钥值。不要遇到失败就遍历历史上所有密钥,这会增加攻击面与 CPU 成本。若提供方 header 自带版本或 key ID,严格校验允许集合。
Stripe 的签名排错文档强调验证时需要 endpoint secret、原始请求体与实际的签名 header;测试环境、CLI 转发和 Dashboard endpoint 的 secret 不能混用。这个边界适用于所有提供方:按 endpoint、环境和用途隔离密钥,不能用一个全局 secret 覆盖 dev、staging 与 production。
密钥应来自受控 secret manager,在进程内短暂持有;轮换与读取需要审计。Webhook 处理日志中只保留 provider、endpoint ID、算法、key ID、delivery ID hash、验证结果和失败类别。
如何处理多签名与算法升级?
提供方在轮换期间可能同时发送多个签名。解析器必须遵守官方格式,保留同名字段或逗号分隔值,不能让通用 header map 悄悄覆盖前一个值。验证规则应是“至少一个受信 key + 允许算法的签名匹配”,而不是“接受任意一个看起来像 hash 的值”。
算法版本写入策略配置。升级时先让接收端支持新版本并继续验证旧版本,再切换发送端,最后在观测窗口后移除旧版本。禁止根据请求 header 动态 import 算法或允许调用方任意选择 digest。
如果需要跨代理、覆盖 method、authority、path 与选定 headers 的通用 HTTP 签名,可研究 RFC 9421 HTTP Message Signatures。但不要为只有 body HMAC 的提供方自创不兼容协议;优先严格实现对方的官方签名规范。
反向代理和框架有哪些坑?
- 请求解压:签名可能针对压缩前或解压后的 bytes,必须遵循提供方规范。
- 字符编码:不要把 bytes 先转字符串再重编码。
- Header 合并:多签名字段可能被覆盖或顺序改变。
- 路径重写:若签名覆盖 URL,内部路径不能代替外部原始 target。
- JSON middleware:自动解析后丢失原始 body。
- 错误日志:框架默认异常页可能回显 header 或 body。
- 代理超时:后端尚未持久化,代理已返回或重试。
部署前应从公网经过真实 CDN/WAF/Nginx/应用链路发送固定测试向量,而不是只在单元测试里直接调用 handler。测试向量应包含合法签名、单字节 body 修改、错误 key、旧时间戳、重复 delivery、多签名、超大 body 和慢速读取。
可观测性与告警应该记录什么?
每次请求记录结构化、脱敏字段:provider、endpoint、delivery ID 的不可逆摘要、body 大小、签名版本、key ID、验证结果、拒绝原因、inbox revision、处理状态与耗时。不要把签名 header 当作普通 debug 字段,因为它可能帮助攻击者分析验证实现。
指标至少包括签名失败率、过期率、重复率、inbox 写入失败、处理 lag、retry/dead-letter 数量和各事件类型的应用结果。突增的签名失败可能是密钥配置错误,也可能是攻击;突增的重复通常说明下游变慢或接收端没有及时返回。
可以配合 FastAPI OpenTelemetry 生产链路追踪指南,为 inbound delivery、durable inbox、worker 与下游调用传播 trace context。但外部提供方给出的 trace header 不能未经校验覆盖你的内部可信采样或 baggage。
上线检查清单
- [ ] 原始 body 在解析与变换前按 bytes 读取,并限制大小与超时。
- [ ] 算法、签名格式、编码和 key ID 使用严格 allowlist。
- [ ] 使用
compare_digest或等价常量时间比较。 - [ ] 若有时间戳,它已被纳入签名输入,并验证合理窗口。
- [ ] delivery ID 用唯一约束持久化,处理权使用 lease/CAS。
- [ ] 业务副作用使用独立的幂等键或版本检查。
- [ ] durable inbox 成功后才返回 2xx。
- [ ] 重试、dead-letter、人工恢复与对账路径已经演练。
- [ ] 新旧密钥轮换窗口有限,环境和 endpoint secret 隔离。
- [ ] 公网真实代理链路的测试向量全部通过。
- [ ] 日志不包含 secret、完整签名或敏感原始 body。
FAQ
HTTPS 已经加密,为什么还要签名?
TLS 保护传输通道并认证你连接的服务端,但接收应用仍需要确认请求符合提供方的 webhook 认证协议。代理、错误路由或任何能访问端点的人都可以发起 HTTPS 请求;签名提供消息级持钥证明。
签名正确就可以立即执行吗?
不可以。还要验证时间窗、delivery 身份、事件 schema、目标资源、业务状态和调用权限。签名只能证明请求与某个 secret 相符,不会自动授权所有业务动作。
能不能只把 delivery ID 放进 Redis 去重?
Redis 可以做快速挡板,但关键副作用需要 durable 记录和业务幂等。缓存丢失、过期或主从切换不应让同一支付、发信或删除动作再次执行。把 Redis 作为优化,而不是唯一事实来源。
返回 2xx 后 worker 永久失败怎么办?
这就是 durable inbox、bounded retry、dead-letter 和人工恢复存在的原因。HTTP 接受只说明事件已可靠接管,不代表业务已完成。运营状态必须继续追踪到 APPLIED 或明确的最终失败。
Webhook 安全不是在路由开头加一段 HMAC 就结束。原始字节、签名、新鲜度、去重、业务幂等、可靠接管和可恢复处理构成一条完整边界;任何一步被折叠,都可能把合法重试变成重复副作用,或把有效签名变成可无限重放的通行证。