Webhook 接收端最容易被忽略的假设是:“签名有效”不等于“请求是新鲜的”,也不等于“业务只会执行一次”。HMAC 可以帮助确认请求内容与共享密钥相符;时间戳可以限制旧请求被接受的时间范围;幂等处理则要防止重复投递造成重复业务效果。这几层控制解决的是不同问题,不能互相替代。
先固定请求处理顺序
建议把接收链路拆成以下步骤:
- 限制请求体大小,并读取原始请求体字节。
- 读取协议约定的签名、时间戳和事件标识等字段。
- 校验必需字段格式及时间戳窗口。
- 根据协议规定的签名原文计算 HMAC,并进行安全比较。
- 验签通过后,基于可信且稳定的事件标识执行原子幂等处理。
- 接收或排队成功后再确认请求;失败时按与发送方约定的方式返回,让重试有机会发生。
签名的具体格式、请求头名称和事件标识含义由发送方协议决定,不应自行假设所有 Webhook 都采用同一种规则。以下示例只展示一种常见设计:签名原文为 时间戳 + "." + 原始请求体,签名算法为 HMAC-SHA256。
原始请求体要在解析前读取
JSON 解析会把字节转换成数据结构。如果之后再把数据结构序列化为 JSON,空格、字段顺序、转义方式或数字格式都可能改变。重新生成的文本不一定与发送方签名时使用的字节相同,因此验签应基于接收到的原始字节,而不是解析后重新拼出的 JSON。
例如,下面两段 JSON 表示的数据可能相同,但字节内容不同:
{"id":7,"status":"paid"}
{ "status": "paid", "id": 7 }
如果协议规定签名基于原始请求体,这两种字节串会产生不同的 HMAC。GitHub 的验证指南也要求在进一步处理交付前验证签名,并使用共享机密验证收到的负载。可参考验证 Webhook 交付。
在 Flask 中,可先调用 request.get_data(cache=True, as_text=False) 取得原始字节,再从这些字节解析 JSON。还应配置合理的请求体大小上限;若代理或框架会解压、转换或重写请求体,要确认验签取得的字节仍与发送方签名所用的内容一致。
校验签名与时间戳
时间戳必须纳入签名原文。若时间戳没有被签名保护,持有一条旧的有效请求的人就可能把时间戳改成当前值,再次提交请求。
下面的 Python 函数演示了核心校验逻辑。它假设发送方使用十六进制编码的 HMAC-SHA256 签名,并在签名原文中使用十进制时间戳、一个点号和原始请求体。实际接入时,应按发送方协议调整格式,不能直接套用示例格式。
import hashlib
import hmac
import time
def verify_webhook(raw_body: bytes, timestamp_text: str,
received_signature: str, secret: bytes,
window_seconds: int = 300) -> bool:
try:
timestamp = int(timestamp_text)
supplied = bytes.fromhex(received_signature)
except (TypeError, ValueError):
return False
if abs(int(time.time()) - timestamp) > window_seconds:
return False
signing_input = timestamp_text.encode("ascii") + b"." + raw_body
expected = hmac.new(secret, signing_input, hashlib.sha256).digest()
return hmac.compare_digest(expected, supplied)
hmac.compare_digest 用于安全比较签名,避免采用普通字符串比较。签名字段缺失、格式错误、时间戳格式异常或超出窗口时,都应在进入业务逻辑前拒绝请求。错误响应不应包含密钥、预期签名或其他敏感实现细节。
时间窗口不是越短越好。窗口太宽会延长旧请求仍可能通过时间检查的时段;窗口太窄则可能拒绝因发送方重试、网络延迟或时钟偏差而晚到的请求。部署前要确认双方时钟同步状况,并结合发送方的投递与重试机制设定窗口。若发送方会在每次重试时重新生成时间戳和签名,重试可能仍能通过时间校验;若重试沿用旧时间戳,则应评估窗口是否覆盖其正常重试周期。不要仅凭通用示例确定具体窗口。
时间窗口不能代替幂等处理
时间戳只能限制请求的时效,不能阻止攻击者在有效窗口内重放一条完整、未被修改的合法请求。应用还需要选取一个稳定的事件标识,例如协议明确提供的事件 ID,或从经过签名的请求内容中提取的业务事件 ID。不要仅因两次请求载荷相同,就自行认定它们一定是同一个事件;不同事件可能具有相同内容。
幂等记录应由数据库唯一约束或等效的原子机制保护,不能只依赖“先查询、再写入”的应用层判断。并发请求可能同时查到记录不存在,随后都执行业务。处理流程可概括为:
验签与时间戳校验通过
↓
在事务中尝试插入 event_id(数据库对该字段设置唯一约束)
├─ 插入成功:记录事件并安排业务处理
└─ 唯一键冲突:确认该事件已接收,按协议返回成功
如果业务操作需要调用外部服务、发放权益或发送消息,单靠数据库唯一键并不能保证外部副作用只发生一次。可在同一数据库事务中写入事件记录和待发送任务,再由后台任务按幂等键执行,并记录处理状态。这样能把“接收事件”和“安排处理”纳入一致的持久化流程;外部系统若支持幂等键,也应传递同一事件标识。
对于需要额外限制“同一份请求只能接受一次”的协议,可以在验签后原子记录已用 nonce,并设置唯一约束和合理过期策略。但 nonce 也不能取代业务事件 ID:发送方可能为同一个业务事件重新投递时生成新的请求标识,而业务效果仍应只发生一次。记录保留时间至少要覆盖业务允许的重试与重复处理周期;具体期限应根据事件生命周期和存储成本确定。
最小接收端伪代码
以下示例以 Python 风格展示流程,db.insert_event_if_absent 和 db.enqueue_in_transaction 表示需要由实际数据库与任务系统实现的原子操作,不是现成的 Flask API。
@app.post("/webhook")
def receive_webhook():
raw_body = request.get_data(cache=True, as_text=False)
timestamp = request.headers.get("X-Webhook-Timestamp")
signature = request.headers.get("X-Webhook-Signature")
if not timestamp or not signature:
return "", 401
if not verify_webhook(raw_body, timestamp, signature, active_secret):
return "", 401
try:
payload = json.loads(raw_body)
event_id = payload["event_id"]
except (ValueError, KeyError, TypeError):
return "", 400
# 在事务内执行:首次接收时写入事件并安排任务;
# 已存在时不重复安排业务操作。
result = db.insert_event_if_absent_and_enqueue(event_id, payload)
if result == "already_seen":
return "", 200
return "", 202
示例中的 event_id 必须来自已验签的内容,或来自签名明确覆盖的请求字段。真实实现还应验证载荷结构和字段类型,并限定事件类型、租户或资源范围。签名校验通过只说明请求与持有共享密钥的一方相符且内容未被篡改;它本身不是完整的身份认证、授权或业务规则校验方案。
返回状态码也要结合发送方协议设计。重复事件通常不应再次执行业务,但如果事件已可靠记录,返回成功可避免发送方持续重试。若首次处理因数据库或队列故障而未能可靠接收,就不应提前返回成功。对于异步处理,通常应在事件已持久化并可恢复后确认接收,而不是等所有耗时业务操作完成才返回。
用失败场景验证修复
测试时应覆盖正常请求、请求篡改、过期请求和并发重复请求,而不只检查接口是否返回成功。
| 测试场景 | 操作 | 预期结果 |
|---|---|---|
| 正常请求 | 用当前时间戳和原始请求体生成有效签名 | 验签通过,事件被记录并安排处理 |
| 篡改载荷 | 保留原签名,只修改请求体中的一个字节 | 验签失败,不进入业务逻辑 |
| 篡改时间戳 | 修改时间戳但不重新生成签名 | 验签失败;若时间戳过期,也应被窗口检查拒绝 |
| 过期请求 | 使用有效签名,但时间戳超出允许窗口 | 被拒绝,不创建幂等记录 |
| 重复请求 | 将同一事件的有效请求提交两次 | 仅首次创建处理任务;重复投递不重复执行业务 |
| 并发重复 | 并发提交同一事件 ID 的多份有效请求 | 数据库唯一约束确保只创建一次处理记录 |
| 格式异常 | 缺少签名头、非十六进制签名或非法 JSON | 在进入业务处理前按接口约定拒绝 |
可在测试中加入计数器或模拟外部副作用,确认重复请求不会重复扣款、发货、发放权益或产生重复消息。还要测试首次接收后处理失败的恢复路径:事件是否能重试或重新投递,是否可能被错误标记为已完成。验收应同时观察 HTTP 响应、数据库记录、任务队列和实际业务副作用。
密钥管理与轮换
共享密钥应使用高熵随机值,并存放在受控的密钥管理设施或受保护的运行时配置中,不要硬编码到源码或提交到代码仓库。限制可读取密钥的服务和人员范围,并避免在日志、异常信息或调试输出中记录密钥、完整签名输入和敏感载荷。GitHub 的指南也建议使用随机机密并安全存储,避免将其硬编码或推送到代码仓库。
轮换时要先确认发送方支持什么更新流程,以及新旧密钥能否短暂并行。若支持并行,可在明确的过渡期内用新密钥签名、服务端同时验证新旧密钥,并记录命中的是哪个密钥版本;确认发送方已完成切换后,再撤销旧密钥。若不支持并行,则应安排双方协调切换与回滚方案,避免把合法重试一并拒绝。无论采用哪种方式,都不要通过放宽时间窗口或跳过签名校验来临时恢复服务。
最终需要维护的是一条完整控制链:原始请求体保证签名计算对象一致,HMAC 与安全比较验证内容,签名覆盖的时间戳限制旧请求,原子幂等记录限制重复业务效果。它能降低伪造、篡改和重放风险,但仍需结合授权规则、业务状态校验、可靠投递和密钥治理共同使用。

广东省深圳市 1F
原来 JSON 空格不同会导致验签失败,这点太容易踩坑了
上海市奉贤区 2F
幂等处理光靠查库确实不行,得用唯一约束才稳
上海市嘉定区 3F
时间窗口设多少秒比较合适?怕设短了误杀重试
上海市青浦区 4F
hmac.compare_digest 这个细节以前真没注意过
重庆市 5F
要是发送方重试时不改时间戳,这逻辑就完美了
山东省烟台市 6F
原始字节读取这一步在 Flask 里很容易写错
甘肃省 7F
密钥轮换那段很实用,正好最近要搞这个
上海市松江区 8F
并发重复请求的测试场景设计得很周全
重庆市 9F
只校验签名不校验幂等,确实会有重复扣款风险
广东省深圳市 10F
这种底层安全逻辑还是得看文章自己实现一遍
山东省烟台市 11F
感觉这已经是「教科书级」接收端设计了
广东省深圳市 12F
看到幂等那段突然想到支付回调,简直一模一样的坑
山东省烟台市 13F
还有人把 webhook 当匿名接口用,不做任何校验真的离谱
甘肃省 14F
想看一篇专门讲幂等键设计取舍的延伸文章
上海市松江区 15F
如果是多租户场景,event_id 里是不是得带上租户维度?
上海市闵行区 16F
密钥轮换这块大家一般是接到通知才切,还是定期主动轮换?
上海市崇明县 17F
测试场景那张表太关键了,感觉可以直接抄去当自测 checklist
广东省深圳市 18F
以后再有人说“签名校验就安全了”,就丢这篇给他