验证 Webhook 签名:防止伪造请求触发你的业务逻辑
Rovela 在每个 Webhook 投递上带 HMAC-SHA256 签名(x-resend-signature: t=...,v1=...)。本文逐步演示验证方法,重点讲清三个容易漏掉的坑:rawBody 必须是原始字节、时间戳防重放、恒定时间比较。

Webhook 在开放网络上接收请求,任何人都可以向你的端点发数据。不验证签名,你就无法区分真实的 Rovela 事件和伪造的请求,而伪造的请求可以触发退款、标记投诉、或任何你在 Webhook 处理器里写的逻辑。
Rovela 在每个 Webhook 投递上都带了签名头,验证过程不复杂,但每一步都不能漏。
签名头的结构
每个 Webhook 请求都带两个头:
x-resend-signature: t=1728820800,v1=a3f2c1d4e5b6...
x-resend-event-type: email.delivered
x-resend-delivery-id: del_...
x-resend-signature 里有两部分,用逗号分开:t 是发送时的 Unix 时间戳(秒),v1 是签名值。签名的计算方式是:用 Webhook 的 signing_secret 对字符串 ${t}.${rawBody} 做 HMAC-SHA256,结果是十六进制字符串。
signing_secret 是创建 Webhook 时接口返回的 whsec_... 字符串,只在创建时出现一次,要立即存好。
验证步骤
第一步:把 rawBody 保留下来。
签名算的是原始 body 字节,不是解析后的 JSON。如果你的框架在你能拿到 rawBody 之前就把 body 解析成了对象,签名必然对不上。
// Express:在 JSON 解析中间件之前注册
app.use('/webhooks/rovela', express.raw({ type: 'application/json' }))
第二步:解析头,提取 t 和 v1。
function parseSignatureHeader(header) {
const parts = Object.fromEntries(
header.split(',').map((p) => p.split('=', 2))
)
return { t: parts.t, v1: parts.v1 }
}
第三步:计算期望签名并对比。
import { createHmac, timingSafeEqual } from 'crypto'
function verifyWebhook(secret, header, rawBody) {
const { t, v1 } = parseSignatureHeader(header)
if (!t || !v1) return false
// 检查时间戳,防止重放攻击(5 分钟窗口)
const age = Math.abs(Date.now() / 1000 - Number(t))
if (age > 300) return false
const payload = `${t}.${rawBody}`
const expected = createHmac('sha256', secret).update(payload).digest('hex')
// 用恒定时间比较,防止时序攻击
return timingSafeEqual(Buffer.from(expected), Buffer.from(v1))
}
第四步:在处理器里调用。
app.post('/webhooks/rovela', (req, res) => {
const header = req.headers['x-resend-signature']
const rawBody = req.body // 此时是 Buffer,因为用了 express.raw()
if (!verifyWebhook(process.env.ROVELA_WEBHOOK_SECRET, header, rawBody)) {
return res.status(401).end()
}
const event = JSON.parse(rawBody)
// 处理 event...
res.status(200).end()
})
如果用 Rovela SDK
SDK 里已经封装了验证逻辑,不需要自己算:
import { verifyWebhookSignature } from 'rovela/webhook'
app.post('/webhooks/rovela', (req, res) => {
try {
const event = verifyWebhookSignature(
process.env.ROVELA_WEBHOOK_SECRET,
req.headers['x-resend-signature'],
req.body // 同样需要是 rawBody,不是解析后的对象
)
// event 已经是解析好的对象
res.status(200).end()
} catch {
res.status(401).end()
}
})
几个容易漏掉的点
rawBody 必须是字节原文:大多数框架的 JSON 中间件会把 body 解析成对象,之后重新序列化得到的字符串和原文几乎必然不一样(字段顺序、空格都可能变)。签名就会对不上,所有合法请求都会被拒。
时间戳校验不能省:签名本身只证明请求来自 Rovela,不证明请求是刚发的。5 分钟窗口可以防重放攻击——攻击者截获了一个合法请求,之后不能无限制地重放它。
用恒定时间比较:普通的字符串相等比较(===)在某些运行时下,两个字符串越相似,比较时间越长,这个时间差可以被用来逐字节猜签名。timingSafeEqual 始终花固定时间,没有这个问题。
分别存每个 Webhook 的 secret:创建多个 Webhook 时,每个都有自己的 signing_secret。把所有 Webhook 都打到同一个端点时,要能区分是哪个 Webhook 发来的,对应用正确的 secret 验证。
更多接口细节见 Webhook 投递文档。