← 中文资源

简体中文版本

Webhook HMAC 签名校验

以恒定时间比较并带时间戳容差,校验 webhook 正文上的十六进制 HMAC 签名。

阅读英文原文

功能说明

该示例实现 webhook 接收方的签名校验:用共享密钥、时间戳与正文计算 HMAC-SHA256,并与请求携带的签名比对。时间戳参与签名可防止重放,超出容许窗口即拒绝。比较使用恒定时间函数,避免通过响应时间逐字节推断正确签名。

关键代码

签名输入采用“时间戳 + 句点 + 正文”的拼接顺序,接收方必须与发送方完全一致,这里把这一点显式写出以免歧义。先比较两个缓冲长度,长度不同直接失败,避免 timingSafeEqual 在长度不一致时抛异常。失败返回带 reason 的对象而非抛错,便于调用方区分超时与不匹配并记录原因,同时不把密钥或期望签名泄漏进日志。

使用注意

验签必须在读取正文之后、任何解析或副作用之前进行,且所用正文须与签名计算时的字节完全一致——框架若重排 JSON 会破坏验签。容差窗口应在重放风险与时钟偏差之间权衡,通常为数分钟。密钥来自密钥管理而非硬编码,轮换时支持同时接受新旧两把密钥。本示例只做验签,不包含幂等去重,重复投递仍需按事件 id 去重。

代码示例

import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyWebhook({ body, signature, timestamp, secret, toleranceMs = 300000, now = Date.now() }) {
  if (typeof body !== 'string' || typeof signature !== 'string' || typeof secret !== 'string') {
    throw new TypeError('body, signature and secret must be strings');
  }
  const ts = Number(timestamp);
  if (!Number.isFinite(ts)) throw new TypeError('timestamp must be numeric');
  if (Math.abs(now - ts) > toleranceMs) return { ok: false, reason: 'timestamp out of tolerance' };
  const expected = createHmac('sha256', secret).update(ts + '.' + body).digest('hex');
  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(signature, 'hex');
  if (a.length !== b.length) return { ok: false, reason: 'signature length mismatch' };
  return timingSafeEqual(a, b) ? { ok: true } : { ok: false, reason: 'signature mismatch' };
}