目标
一个接受任意 POST 的 Webhook URL 就是你域内的开放写入口。签名验证把它关上:提供方用“对确切字节做 HMAC”证明自己持有共享密钥。再补两个细节就完整了:时间戳限定被截获请求的可呈递时长,投递 id 记忆把重放变成“确认但无副作用”而不是二次生效。
步骤要点
- 用 crypto.createHmac(“sha256”, secret).update(timestamp + “.” + rawBody) 计算期望签名,再 timingSafeEqual 恒定时间比较。
- 时间戳偏差超过 300 秒即 400 拒绝,限定截获请求的可用窗口。
- 以投递 id 做幂等键:见过的 id 返回 200 duplicate 无副作用,Redis TTL 覆盖重试周期。
- 用 express.raw 中间件拿原始字节体验签,绝不对解析重序列化后的对象验签。
- 实测:伪造签名得 400,重放合法请求被识别为 duplicate 且无二次副作用。
排错提示
合法签名总被拒几乎都是验了重序列化后的 JSON,键序与空白一变全挂,必须验原始字节并严格按文档拼接格式;重复投递仍被处理两次是 id 存储不持久或 TTL 短于提供方重试周期,按 delivery id 而非时间戳做键;时钟偏差误杀合法事件就对称放宽容忍并在偏差逼近上限时告警。
适用边界
HMAC 只证明发送方持有密钥,发送方自身被攻陷接收方无法察觉;防重放的强度等于投递 id 存储的持久性,带 TTL 的 Redis 覆盖典型重试周期即可。
代码示例
const expected = crypto.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))
&& Math.abs(Date.now() / 1000 - Number(timestamp)) < 300;