所有文章
webhook securityHMACsignature verificationbest practices

Webhook 签名校验,不再神秘

大多数 Webhook 签名校验失败不是加密 bug,而是解析 bug。有人在校验前就解析了请求体。有人在服务商需要 base64 时用了 hex。有人在需要时间安全比较时用了 ===。本指南是我们调试过的每一个签名问题的精简版。

服务商使用的模型

每个主流 Webhook 服务商——Stripe、GitHub、Shopify、Twilio、Slack、Discord——本质上都在做同一件事,只是换了外衣:

  1. 你和服务商共享一个密钥。
  2. 服务商对请求体(有时加上时间戳,有时加上 URL)计算一个签名。
  3. 签名随一个请求头一起发送。
  4. 你的处理器用同一个密钥重新计算签名,若不匹配则拒绝该请求。

各服务商之间的差异:算法(HMAC SHA-256 最常见;Discord 用 Ed25519)、编码(hex 还是 base64)、签名对象(请求体、请求体+时间戳、URL+请求体+参数),以及哪个请求头携带签名。

几乎涵盖一切的四个 bug

1. 先解析了请求体

大多数框架都有一个 JSON 中间件,把进来的请求体解析成对象。它一旦运行,原始字节就没了,你计算的任何签名都不会匹配——即使你把解析后的对象重新序列化,空白和键的顺序也可能不同。

修复:在 Webhook 路由上,在任何中间件之前读取原始请求体。在 Express 中,仅在 Webhook 路径上挂载 express.raw({ type: 'application/json' })。在 Next.js App Router 中,直接读取 await request.text()。在 Django 中,在任何 DRF 解析器运行之前使用 @csrf_exempt + request.body

2. 编码错误

Stripe 和 GitHub 用 hex。Shopify 用 base64。同一个密钥在每种里产生外观不同的输出。我们已经在 Shopify 指南 里讲过——这坑住了每个人。

3. 非时间安全

字符串相等(===)在第一个不匹配的字符处就提前返回。“匹配第一个字符”和“匹配全部 64 个字符”之间的时间差会泄露关于密钥的信息。使用时间安全比较:

// Node
crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));

// Python
hmac.compare_digest(a, b)

// Ruby
ActiveSupport::SecurityUtils.secure_compare(a, b)

实际上,针对公网上的 Webhook 端点发动这种攻击很难,但安全比较成本极低,没有理由不用。

4. 未校验时间戳

如果签名只覆盖请求体,捕获到一个有效请求的攻击者就能永远重放它。服务商通过把时间戳纳入签名负载并在文档中给出容差窗口来解决这个问题(Stripe:5 分钟;GitHub:也很短)。你的处理器应当拒绝任何时间戳早于容差的签名。

跳过这一步,你就构建了一个易受重放攻击的系统,无论 HMAC 运行得多完美。

请求头里到底是什么

服务商的请求头携带的不止“签名”。Stripe 发送 t=1234567890,v1=abcdef...,v0=...——时间戳加上多个签名版本。GitHub 发送带前缀的 sha256=abcdef...。Twilio 把请求 URL 纳入签名负载,但只在 X-Twilio-Signature 中发送签名本身。阅读你所对接服务商的文档,然后按其确切格式实现。

本地测试让它可调试

签名校验是最糟糕的一类 bug:无声且在预发布环境难以复现。用 隧道 和请求捕获进行本地测试,能给你失败的确切负载、到达的确切请求头,以及一个重放按钮,让你在不打扰服务商的情况下重新测试修复。

当校验拒绝某个请求时,记录足够的信息以便调试:原始请求体的长度、你读取的请求头、你计算出的签名。不要记录密钥。(我们见过有人记录密钥。别这么干。)

一个最小的正确校验器

// Express + Node, generic HMAC SHA-256 hex provider
app.post(
  '/webhooks/foo',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const signature = req.headers['x-foo-signature'];
    const timestamp = req.headers['x-foo-timestamp'];

    // 1. Reject old timestamps (5 min tolerance)
    if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
      return res.status(400).send('stale');
    }

    // 2. Compute expected signature
    const expected = crypto
      .createHmac('sha256', process.env.FOO_SECRET)
      .update(`${timestamp}.${req.body}`)
      .digest('hex');

    // 3. Timing-safe compare
    if (!crypto.timingSafeEqual(
      Buffer.from(expected),
      Buffer.from(signature),
    )) {
      return res.status(401).send('invalid');
    }

    // 4. Now safe to parse and process
    const event = JSON.parse(req.body);
    handleEvent(event);
    res.status(200).end();
  },
);

当签名校验仍然失败

如果以上都对,它仍然失败,再检查三件事:密钥从你的 env 文件里带了尾部空白、你的隧道改写了某个请求头(大多数不会,但用请求捕获确认一下是值得的),或者你在用 live 密钥测试而控制台显示的是 test 密钥。这三种我们都遇到过。

关于各服务商的坑,参见 StripeGitHubShopify 指南。或者如果你此刻正盯着一个 401,跳到 调试 Webhook 401 错误加入 PortPreview 等候名单,获得让这一切可调试的隧道 + 捕获。

常见问题

为什么我的 Webhook 签名校验失败?
大多数时候是中间件在校验运行之前就解析了请求体。其他常见原因:hex 与 base64 编码不匹配、非时间安全比较在某些边缘情况下失败,或使用了错误的密钥(test 与 live、控制台与 CLI)。
Webhook 签名真的需要时间安全比较吗?
需要。普通字符串相等会逐字节泄露关于密钥的时间信息。这种攻击在实践中难以发动,但安全比较只是一行代码,没有理由不用。
为什么我的 Webhook 签名包含时间戳?
没有时间戳,捕获到有效签名请求的攻击者就能永远重放它。时间戳让你的处理器拒绝任何早于服务商容差窗口(通常几分钟)的请求。