← 返回博客

验证 Webhook 签名:防止伪造请求触发你的业务逻辑

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

更新于 2026年10月13日
验证 Webhook 签名:防止伪造请求触发你的业务逻辑

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 投递文档。