Skip to Content
Webhook签名验证

签名验证

每次 Webhook 投递都会带上两个配套使用的标头:

X-Signature: sha256=9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b X-Timestamp: 1729536000

sha256= 后的十六进制字符串是 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' })

实现示例

lib/verify-infraio.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 等高价值事件推荐):

  1. X-Delivery 去重,使用带唯一约束的表。容差窗口内的重放 就成了 no-op — 你的处理函数返回 200 而不会做两遍。这正是你也想 要的合法重试的幂等性。(X-Delivery 在一次投递的所有重试间保持稳定; 载荷中并没有 event_id 字段。)
  2. 使用你时钟漂移所能允许的最小容差。 ±5 分钟是推荐默认值, 与大多数 NTP 同步的车队能维持的范围一致。更紧也可以;但低于 ±30 秒后,上游 NTP 较慢的网络上你会开始拒绝合法投递。

轮换密钥

  1. 仪表板 → Developers → Webhooks → [endpoint] → ⋮ → Rotate Secret。
  2. 生成新密钥并仅显示一次。关闭对话框前请复制。
  3. 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 流程触发真实事件。

常见失败

症状可能原因
开发环境永远返回 falseHMAC 计算前 body 已被 JSON 解析。请先读取原始字节。
昨天还行,今天失败你轮换了密钥但这台服务器的 env var 还是旧的。用新密钥重新部署。
旧事件失败,新事件成功一次投递在轮换前已入队;签名用旧密钥而你的验证器不再接受。等重试自然丢弃,或通过仪表板重放。
时间戳比较差一确保用 Unix 秒比较 Unix 秒。JS 的 Date.now()毫秒 — 要除以 1000。
本地正常,生产失败代理(Cloudflare、nginx)在解压、再编码或剥掉了末尾换行。检查处理函数实际看到的字节。
测试 ping 报 401 / 签名不匹配你的验证器只签了 body(2026 年前的方案)。请改为签 timestamp + "." + body
整个标头丢失端点注册在另一个环境下。Test 模式端点只接收 environment=test 事件。