Webhook HMAC 重放防护:时间戳、重试与幂等性
Webhook HMAC 重放防护需要两道彼此独立的控制。首先,对准确的时间戳与原始请求体计算 HMAC-SHA256,并拒绝超出自定义新鲜度窗口的投递。其次,对稳定的事件 ID 去重,因为真实、合法的投递也可能被重试。签名验证能够证明数据完整性,并确认发送方掌握共享密钥;它无法让投递自动变成 exactly-once。
Webhook HMAC 重放防护如何工作
安全的接收端应按顺序回答四个问题:
- 签名请求头是否存在且格式正确? 如果端点已配置签名,应拒绝缺少时间戳、签名或事件 ID 的请求。
- 请求是否足够新? 解析 RFC 3339 时间戳,并应用为自身基础设施选定的新鲜度窗口。
- 原始字节是否与签名匹配? 对
<timestamp>.<raw body>计算 HMAC-SHA256,并以恒定时间比较摘要。 - 这个事件是否已被接收? 在确认投递之前,以唯一约束保存稳定的事件 ID。
最后一项检查很重要,因为 retry 与 replay 并不是一回事。retry 是连接失败或收到非 2xx 响应后的合法再次投递;replay 则是在预期处理路径之外再次使用曾经有效的签名请求。时间戳新鲜度限制了已被获取的请求还能被接受多久;持久化幂等机制则避免一次有效重试重复创建同一工单、回复或工作流。
关键要点
- 在解析 JSON 或重新序列化之前,先验证原始请求字节。
- 将时间戳新鲜度视为应用策略;UnifyPort 不会为所有部署规定同一个固定容差。
- 对长度相同的摘要缓冲区使用恒定时间比较。
- 对
X-Device-Event-Id去重,因为 UnifyPort 采用 at-least-once 投递。 - 只在事件已被持久化接收后返回 2xx,而不是等所有下游工作完成后再响应。
UnifyPort 的精确签名约定
Webhook 端点配置 signing_secret 后,UnifyPort 会发送十六进制编码的 X-Device-Signature。被签名的内容是:
<X-Device-Timestamp>.<raw request body>
X-Device-Timestamp 是 RFC 3339 UTC 值,而不是 Unix 整数。同一事件重试时,X-Device-Event-Id 保持不变;X-Device-Delivery-Id 则标识单次投递尝试。如果省略 signing_secret 或将其留空,签名会被禁用,也不会发送签名请求头。
这一约定遵循 RFC 2104 对 HMAC 的定义:共享密钥的双方可以检查消息完整性,并验证发送方是否掌握该密钥。HMAC 不会加密请求体,不会自行证明请求的新鲜度,也不承诺只投递一次。这些保证分别来自 HTTPS、时间戳策略,以及围绕 HMAC 检查建立的幂等存储。
如果你要构建完整的入站工作流,n8n WhatsApp Webhook 教程展示了签名事件如何进入自动化流程;TikTok 实时私信队列教程则说明了为何同一套已验证事件信封应先存储、再路由。
在 Node.js 中验证时间戳和原始请求体
下面的接收端将请求体保留为 Buffer,从部署配置读取新鲜度容差,使用 Node.js 的 crypto.timingSafeEqual 比较二进制摘要,再把已验证事件交给持久化收件箱。durableInbox.insertIfAbsent 代表一次受事件 ID 唯一键保护的数据库插入;请用服务当前采用的数据存储来实现它。
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const secret = process.env.WEBHOOK_SIGNING_SECRET;
const maxAgeMs = Number(process.env.WEBHOOK_MAX_AGE_MS);
if (!secret || !Number.isFinite(maxAgeMs) || maxAgeMs <= 0) {
throw new Error('Configure WEBHOOK_SIGNING_SECRET and WEBHOOK_MAX_AGE_MS');
}
app.post(
'/webhooks/unifyport',
express.raw({ type: 'application/json' }),
async (req, res) => {
const timestamp = req.get('X-Device-Timestamp') ?? '';
const signature = req.get('X-Device-Signature') ?? '';
const eventId = req.get('X-Device-Event-Id') ?? '';
if (!timestamp || !signature || !eventId) {
return res.sendStatus(401);
}
const signedAtMs = Date.parse(timestamp);
const ageMs = Math.abs(Date.now() - signedAtMs);
if (!Number.isFinite(signedAtMs) || ageMs > maxAgeMs) {
return res.sendStatus(401);
}
const expected = crypto
.createHmac('sha256', secret)
.update(timestamp + '.')
.update(req.body)
.digest();
const validHex = /^[0-9a-f]{64}$/i.test(signature);
const provided = validHex ? Buffer.from(signature, 'hex') : Buffer.alloc(0);
const validSignature =
provided.length === expected.length &&
crypto.timingSafeEqual(provided, expected);
if (!validSignature) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString('utf8'));
const accepted = await durableInbox.insertIfAbsent({
id: eventId,
occurredAt: event.occurred_at,
payload: event,
});
return res.sendStatus(accepted ? 202 : 200);
},
);
新鲜度检查位于 HMAC 比较之前,但只有两项检查都通过后,时间戳才会被信任。接收端只是提前拒绝明显过期的输入。WEBHOOK_MAX_AGE_MS 的取值应反映你的时钟同步状况、正常投递延迟、事件处理流程和风险模型。不要直接照搬无关提供方的容差,并默认它适合你的队列。
Node.js 文档说明 crypto.timingSafeEqual 适合比较 HMAC 摘要,同时也提醒外围代码不能引入计时信息泄露。应先验证十六进制格式和字节长度,因为 timingSafeEqual 要求两个输入长度相等。
让确认路径安全应对重试
UnifyPort 将任何 2xx 响应视为确认,并丢弃响应体。连接错误以及 HTTP 408、429 和 5xx 响应可以按端点配置的 retry_policy.max_attempts 重试;默认值为 3。其他 4xx 响应不会重试,事件会进入 dead letter。
根据这一行为,可以设计出清晰的接收端:
| 接收端结果 | 响应 | 原因 |
|---|---|---|
| 签名缺失、过期或无效 | 401 | 请求不应进入可信队列。 |
| 已存储的已验证事件 | 200 | 可以安全确认重试,无需重复执行工作。 |
| 已持久化插入的已验证事件 | 202 | 接收完成后,worker 可以继续异步处理。 |
| 持久化收件箱暂时不可用 | 503 | 与其确认一个尚未存储的事件,让系统重试更安全。 |
请对 X-Device-Event-Id 建立唯一索引,而不是使用进程内的 Set。本地缓存会在重启后消失,也无法协调多个接收端实例。下游操作也要保持幂等:队列 consumer 可能在调用 CRM 或发送回复之后、记录完成状态之前崩溃。
投递顺序不受保证。处理会改变状态的工作时,应按事件 payload 的 occurred_at 排序,并以事件 ID 作为顺序相同时的判定值,而不是假设 HTTP 到达顺序就是事件顺序。当已读回执可能早于其引用的消息抵达端点时,这一点尤其重要。
UnifyPort 的适用位置
UnifyPort 会跨受支持的渠道投递同一套标准事件信封,其中包括 id、type、provider、account_id、occurred_at 和事件专属的 data。因此,上面的接收端保护的是一个统一入口,而不是六套渠道专属 handler。只需注册一次端点,启用 signing_secret,订阅所需事件,并在按 provider 或 type 路由之前应用同样的时间戳、签名和幂等检查。
重要边界在于存储:UnifyPort 不会保存消息历史供日后回填。Webhook 事件就是流量记录,因此接收端应先持久化接收,再返回 2xx。签名验证保护交接过程;收件箱表或队列则负责留存事件。
局限与权衡
- HMAC 用于验证来源并保护完整性;它不会加密 JSON 请求体。请持续启用 HTTPS,并分别保护日志和队列。
- 有效的 HMAC 无法阻止重复处理。仍需使用时间戳新鲜度和幂等键。
- UnifyPort 不会公布一套通用的时间戳容差。较短的窗口会限制请求再次被使用,但对时钟漂移或延迟投递的容忍度也更低。
- 如果端点禁用了签名,
X-Device-Signature就不会出现。要求身份验证的生产接收端应在缺少该请求头时采用 fail closed。 - 官方提供方的 Webhook 可能使用不同的请求头、编码或 canonical string。请遵循各提供方自己的约定,不要把 UnifyPort 的字符串格式套用到所有 Webhook 来源。
常见问题
为什么我的 Webhook HMAC 签名不匹配?
最常见的原因是验证了解析后或重新序列化的 JSON,而不是准确的原始字节。还要检查 RFC 3339 时间戳、字面量点号分隔符、正确的 signing_secret、十六进制解码,以及 middleware 是否在验证前消费了请求体。
仅使用 HMAC 能阻止 Webhook 重放吗?
不能。HMAC 只能证明签名字节与共享密钥匹配。还要检查 X-Device-Timestamp 的新鲜度,并对 X-Device-Event-Id 进行持久化去重,以限制请求再次使用和重复处理。
重复事件应该返回错误吗?
不应该。如果同一个事件 ID 已被持久化接收,请返回 2xx。返回错误只会触发另一次合法重试,却不会提高正确性。
应该在处理事件之前确认吗?
应在持久化接收之后、耗时的下游工作之前确认。先将事件插入数据库支持的收件箱或持久化队列,返回 2xx,再由 worker 以幂等方式处理 CRM 写入、AI 处理或回复。
应该使用多长的新鲜度窗口?
请根据已同步的时钟、观测到的投递延迟、事件处理方式和风险模型选择并记录窗口。UnifyPort 的签名约定要求拒绝与本地时钟相差过大的时间戳,但不规定唯一的固定值。
下一步
按照 Webhook 投递与签名验证指南实现准确的请求头和重试约定。诊断签名不匹配时,如需快速逐字节检查,可使用 HMAC 签名生成器作为唯一的辅助工具。
来源
官方来源核验于 2026 年 7 月 17 日: