签名验证
每次 Webhook 投递都会带上两个配套使用的标头:
X-Signature: sha256=9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b
X-Timestamp: 1729536000sha256= 后的十六进制字符串是 HMAC-SHA256(secret, timestamp + "." + raw_body)。
点号是一个字面量字节,timestamp 是 ASCII 形式的 Unix 秒。
为什么要验证
Webhook URL 会泄露。它们会出现在代理日志、截图、浏览器历史、
合作伙伴的支持工单里。没有签名校验,任何知道你 URL 的人都能 POST
一个伪造的 payment.settled 事件,骗你为未付订单履约。验证从
密码学层面证明请求来自 InfraIO。
把 timestamp 放进被签名的负载里还能提供防重放:攻击者即使 捕获了一次投递,过了你的容差窗口后,签名就会变得可被检测为陈旧。
算法
signed_payload = timestamp + "." + raw_body
expected_header = "sha256=" + lowercase_hex( HMAC_SHA256(secret, signed_payload) )
constant_time_compare(expected_header, x_signature_header)然后检查 timestamp 是新的(典型容差:±5 分钟)。
始终传原始请求 body 字节。框架经常在你的处理函数运行前就解析了
JSON;重新序列化后的版本可能和我们发送的不同(键序、空白、数字
格式),HMAC 就对不上。Next.js App Router 里,在 JSON.parse
之前使用 await req.text()。Express 里,仅在 Webhook 路由上
挂载 express.raw({ type: 'application/json' })。
实现示例
Node / TS
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 5 * 60;
export function verifyInfraIo({
body,
signature,
timestamp,
secret,
}: {
body: string; // 原始文本 — 不是解析过的 JSON
signature: string; // X-Signature 标头的值
timestamp: string; // X-Timestamp 标头的值(Unix 秒)
secret: string; // whsec_…
}): boolean {
const ts = Number.parseInt(timestamp, 10);
if (!Number.isFinite(ts)) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - ts) > TOLERANCE_SECONDS) {
return false; // 太旧或太靠未来
}
const expected = "sha256=" + createHmac("sha256", secret)
.update(`${timestamp}.${body}`)
.digest("hex");
const a = Buffer.from(signature);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}防重放
签名负载里的 timestamp 是第一道防线 — 捕获了一次投递的攻击者 在你的容差窗口过期后无法再重发。
双保险(对 payment.settled 等高价值事件推荐):
- 用
X-Delivery去重,使用带唯一约束的表。容差窗口内的重放 就成了 no-op — 你的处理函数返回 200 而不会做两遍。这正是你也想 要的合法重试的幂等性。(X-Delivery在一次投递的所有重试间保持稳定; 载荷中并没有event_id字段。) - 使用你时钟漂移所能允许的最小容差。 ±5 分钟是推荐默认值, 与大多数 NTP 同步的车队能维持的范围一致。更紧也可以;但低于 ±30 秒后,上游 NTP 较慢的网络上你会开始拒绝合法投递。
轮换密钥
- 仪表板 → Developers → Webhooks → [endpoint] → ⋮ → Rotate Secret。
- 生成新密钥并仅显示一次。关闭对话框前请复制。
- 24 小时内更新你的环境变量并重新部署验证器。
宽限窗口(双签)
轮换后的 24 小时 内,每次投递都带两个签名:
X-Signature: sha256=<hmac(new_secret, ts + "." + body)>
X-Signature-Prev: sha256=<hmac(prev_secret, ts + "." + body)>
X-Timestamp: 1729536000运行前一个密钥的验证器匹配 X-Signature-Prev;运行新密钥的
验证器匹配 X-Signature。任一标头通过就够了 — 处理函数可以在
迁移期接受投递,无需阻塞部署。
宽限窗口关闭后只发送 X-Signature。前一个密钥不再被接受,任何还
配置着它的验证器都会开始拒绝投递 — 所以请在 24 小时预算内完成
发布。
推荐的接收方模式
// 轮换宽限窗口内接受任一签名。
const sig = req.headers["x-signature"] ?? "";
const sigPrev = req.headers["x-signature-prev"] ?? "";
const ok = verify(body, sig, ts, CURRENT_SECRET)
|| (PREV_SECRET && verify(body, sigPrev, ts, PREV_SECRET));一旦你端点的宽限窗口过期、并且 env 里的 PREV_SECRET 已移除,
就可以删掉 X-Signature-Prev 分支。
紧急失效
如果密钥公开泄露,你想立刻让前一个密钥失效 — 即不希望 24 小时 重叠让已知有问题的密钥继续存活 — 那就轮换两次。第一次轮换把泄露 的密钥移到 prev 槽;第二次轮换把它从 prev 槽挤出(被仍然新的密钥 取代),泄露的值就再也不被接受。
配线测试
仪表板里打开 Developers → Webhooks,在你想验证的端点上点击 Send Test。我们会同步对 URL 进行签名并 POST 一个合成信封, 然后展示 HTTP 状态、延迟以及你响应的前 512 字节。负载形状:
{
"event_id": "<uuid>",
"event_type": "webhook.test.ping",
"created_at": "2026-05-17T12:00:00Z",
"test": true,
"data": {
"merchant_id": "<your-merchant-id>",
"webhook_id": "<endpoint-id>",
"message": "Test ping from the merchant dashboard..."
}
}测试 ping 与生产投递使用相同的签名方案,所以这个按钮出现绿色 对勾就确认你的验证器也能接受真实事件。测试 ping 绕过 RMQ 重试 管道 — 如果你想演练重试,请通过相应 API 流程触发真实事件。
常见失败
| 症状 | 可能原因 |
|---|---|
| 开发环境永远返回 false | HMAC 计算前 body 已被 JSON 解析。请先读取原始字节。 |
| 昨天还行,今天失败 | 你轮换了密钥但这台服务器的 env var 还是旧的。用新密钥重新部署。 |
| 旧事件失败,新事件成功 | 一次投递在轮换前已入队;签名用旧密钥而你的验证器不再接受。等重试自然丢弃,或通过仪表板重放。 |
| 时间戳比较差一 | 确保用 Unix 秒比较 Unix 秒。JS 的 Date.now() 是毫秒 — 要除以 1000。 |
| 本地正常,生产失败 | 代理(Cloudflare、nginx)在解压、再编码或剥掉了末尾换行。检查处理函数实际看到的字节。 |
| 测试 ping 报 401 / 签名不匹配 | 你的验证器只签了 body(2026 年前的方案)。请改为签 timestamp + "." + body。 |
| 整个标头丢失 | 端点注册在另一个环境下。Test 模式端点只接收 environment=test 事件。 |