跳转到内容
Tutorial

Webhook 安全性:HMAC 签名、幂等性和重放保护

| 9 min read

三种代码模式可阻止欺骗性负载、重复传送和重播 Webhook 请求。 具有 HMAC-SHA256 和时间戳检查的 Node.js 示例。

Digital encryption padlock on a circuit board
Photo by Adi Goldstein on Unsplash

您的 Webhook 处理程序接受 POST 请求、解析 JSON 并运行业务逻辑。 这一直有效,直到有人 向您的端点发送伪造的有效负载。 或者直到提供商重试交付并且您的处理程序收取费用 客户两次。 或者直到攻击者记录合法请求并在六小时后重放。

三种保护措施解决了这个问题:HMAC 签名验证、幂等性密钥和基于时间戳的重放拒绝。 本教程涵盖了每一项以及可用的 Node.js 代码。

Webhook 签名的工作原理

每个主要的 Webhook 提供商(Stripe、GitHub、Shopify、Twilio)都使用 HMAC-SHA256 签署传出有效负载。 的 提供者将原始请求正文与共享密钥相结合,计算哈希值,然后在标头中发送该哈希值。 您的服务器使用相同的秘密重新计算哈希并进行比较。 如果哈希值匹配,则有效负载是真实的。

签名公式如下所示:

HMAC-SHA256(secret, timestamp + "." + raw_body) = signature

Stripe 发送签名 Stripe-Signature。 GitHub 发送进来 X-Hub-Signature-256。 算法是一样的; 仅标头名称和格式不同。

验证 Node.js 中的 Stripe Webhook 签名

条纹的 Stripe-Signature 标头包含时间戳(t=) 和一个或多个版本 签名(v1=)。 以下是在没有 Stripe SDK 的情况下验证它的方法:

const crypto = require('crypto');

function verifyStripeSignature(payload, header, secret) {
  const parts = header.split(',');
  const timestamp = parts.find(p => p.startsWith('t=')).slice(2);
  const signature = parts.find(p => p.startsWith('v1=')).slice(3);

  const signedPayload = timestamp + '.' + payload;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex');

  if (expected !== signature) {
    throw new Error('Invalid signature');
  }

  // Replay protection: reject if older than 5 minutes
  const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
  if (age > 300) {
    throw new Error('Timestamp too old');
  }

  return JSON.parse(payload);
}

关键细节:Stripe 使用句点分隔符连接时间戳和原始正文。 这 v1 签名使用HMAC-SHA256。 最后的时间戳检查提供重放保护(下面详细介绍)。

验证 Node.js 中的 GitHub Webhook 签名

GitHub 的格式更简单。 这 X-Hub-Signature-256 标头包含 sha256= 已关注 根据原始主体的十六进制 HMAC:

const crypto = require('crypto');

function verifyGitHubSignature(payload, signatureHeader, secret) {
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex');

  const trusted = Buffer.from(expected, 'utf8');
  const untrusted = Buffer.from(signatureHeader, 'utf8');

  if (trusted.length !== untrusted.length) {
    throw new Error('Invalid signature');
  }

  if (!crypto.timingSafeEqual(trusted, untrusted)) {
    throw new Error('Invalid signature');
  }

  return JSON.parse(payload);
}

注意 crypto.timingSafeEqual。 一个标准 === 比较会泄漏时序信息。 攻击者可以测量响应时间来逐字节猜测正确的签名。 恒定时间比较 消除该向量。

使用 botoi 的哈希 API 计算 HMAC 签名

如果您需要从脚本、CI 管道或无服务器函数计算或验证 HMAC-SHA256 签名 无需导入 crypto,使用 /v1/hash/hmac 端点:

curl -X POST https://api.botoi.com/v1/hash/hmac \\
  -H "Content-Type: application/json" \\
  -d '{
    "text": "1712345678.{\\"event\\":\\"payment_intent.succeeded\\",\\"amount\\":4999}",
    "key": "whsec_your_stripe_secret",
    "algorithm": "sha256"
  }'

回复:

{
  "success": true,
  "data": {
    "hmac": "a3f2b8c9d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0",
    "algorithm": "sha256"
  }
}

这将返回十六进制编码的 HMAC。 将其与 Webhook 提供商的签名标头进行比较。 端点同时支持 sha256sha512 算法。

构建用于 HMAC 验证的 Express 中间件

将签名检查包装到可重用的中间件中,以便每个 Webhook 路由都得到保护,而无需重复代码:

const crypto = require('crypto');

function webhookAuth(secret) {
  return (req, res, next) => {
    const signature = req.headers['x-webhook-signature'];
    const timestamp = req.headers['x-webhook-timestamp'];

    if (!signature || !timestamp) {
      return res.status(401).json({ error: 'Missing signature headers' });
    }

    // Step 1: Replay protection
    const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
    if (age > 300) {
      return res.status(401).json({ error: 'Request too old' });
    }

    // Step 2: HMAC verification
    const payload = JSON.stringify(req.body);
    const expected = crypto
      .createHmac('sha256', secret)
      .update(timestamp + '.' + payload)
      .digest('hex');

    const trusted = Buffer.from(expected, 'utf8');
    const untrusted = Buffer.from(signature, 'utf8');

    if (trusted.length !== untrusted.length ||
        !crypto.timingSafeEqual(trusted, untrusted)) {
      return res.status(401).json({ error: 'Invalid signature' });
    }

    next();
  };
}

// Mount on your webhook route
app.post('/webhooks/stripe', webhookAuth(process.env.STRIPE_WEBHOOK_SECRET), handler);

该中间件会拒绝在您发送请求之前缺少标头、过时时间戳或无效签名的请求。 处理程序运行。 300 秒的窗口符合 Stripe 的默认容差。

幂等性:跳过重复传递

Webhook 提供商重试失败的传送。 如果您的服务器在处理事件之后但之前返回 500 发送 200 响应后,提供者会再次发送相同的事件。 如果没有幂等性,您的处理程序将运行 两次副作用:双重收费、重复的电子邮件、重复的库存更新。

解决方法:存储每个已处理的事件 ID 并跳过您之前见过的事件。

内存幂等性(开发)

const processedEvents = new Map(); // Use Redis in production

async function handleWebhook(req, res) {
  const eventId = req.body.id;

  // Check if already processed
  if (processedEvents.has(eventId)) {
    return res.status(200).json({ status: 'duplicate', eventId });
  }

  // Mark as processing before side effects
  processedEvents.set(eventId, Date.now());

  try {
    await processEvent(req.body);
    return res.status(200).json({ status: 'processed', eventId });
  } catch (err) {
    // Remove on failure so retries work
    processedEvents.delete(eventId);
    return res.status(500).json({ error: 'Processing failed' });
  }
}

Map 为当地发展而努力。 在生产中,使用共享存储,以便所有服务器实例 查看同一组已处理的事件。

Redis 支持的幂等性(生产)

const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);

async function handleWebhook(req, res) {
  const eventId = req.body.id;
  const lockKey = \`webhook:processed:\${eventId}\

SET NX 是原子的:只有一个进程赢得锁。 72 小时 TTL (259200 秒) 自动修剪旧条目。 如果处理失败,处理程序将删除该密钥,以便下次重试能够成功。

重放保护:拒绝过时的请求

HMAC 验证确认有效负载来自提供商。 但是由 a 捕获的有效签名请求 除非您添加时间检查,否则网络攻击者将永远有效。 重放保护拒绝有效负载 时间戳早于阈值。

function rejectStaleRequests(req, res, next) {
  const timestamp = parseInt(req.headers['x-webhook-timestamp'], 10);

  if (isNaN(timestamp)) {
    return res.status(401).json({ error: 'Missing timestamp' });
  }

  const now = Math.floor(Date.now() / 1000);
  const age = now - timestamp;

  // Reject anything older than 5 minutes or from the future
  if (age > 300 || age < -30) {
    return res.status(401).json({ error: 'Timestamp out of range' });
  }

  next();
}

30 秒的未来容差可以处理您的服务器和提供商之间的微小时钟偏差。 任何东西 超过 5 分钟或超过 30 秒的未来将被拒绝。

将所有三者合并为一个中间件

这是按顺序运行所有三个检查的完整中间件:时间戳、HMAC,然后是幂等性。

const crypto = require('crypto');
const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);

function secureWebhook(secret) {
  return async (req, res, next) => {
    const signature = req.headers['x-webhook-signature'];
    const timestamp = req.headers['x-webhook-timestamp'];
    const eventId = req.body?.id;

    // 1. Reject missing headers
    if (!signature || !timestamp || !eventId) {
      return res.status(401).json({ error: 'Missing required headers or event ID' });
    }

    // 2. Replay protection: reject stale requests
    const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
    if (age > 300 || age < -30) {
      return res.status(401).json({ error: 'Timestamp out of range' });
    }

    // 3. HMAC verification
    const payload = JSON.stringify(req.body);
    const expected = crypto
      .createHmac('sha256', secret)
      .update(timestamp + '.' + payload)
      .digest('hex');

    const trusted = Buffer.from(expected, 'utf8');
    const untrusted = Buffer.from(signature, 'utf8');

    if (trusted.length !== untrusted.length ||
        !crypto.timingSafeEqual(trusted, untrusted)) {
      return res.status(401).json({ error: 'Invalid signature' });
    }

    // 4. Idempotency: skip duplicates
    const lockKey = \`webhook:processed:\${eventId}\

顺序很重要。 时间戳是最便宜的检查(无 I/O),因此它首先运行。 HMAC验证是 受 CPU 限制但仍然很快。 幂等性需要 Redis 调用,因此它最后运行。 该序列拒绝 以最低的成本获得最多的请求。

使用 botoi 的 webhook 收件箱测试您的 webhook 处理程序

使用按钮的 /v1/webhook/inbox 要创建一次性 URL,请使用以下命令计算 HMAC 签名 /v1/hash/hmac,并发送签名的测试负载。 没有隧道,没有本地服务器。

# 1. Create a webhook inbox
INBOX=\$(curl -s -X POST https://api.botoi.com/v1/webhook/inbox/create)
INBOX_ID=\$(echo \$INBOX | jq -r '.data.inbox_id')
INBOX_URL=\$(echo \$INBOX | jq -r '.data.url')
echo "Inbox URL: \$INBOX_URL"

# 2. Compute an HMAC signature for your test payload
PAYLOAD='{"id":"evt_001","event":"order.created","amount":2999}'
TIMESTAMP=\$(date +%s)
SIGNED_PAYLOAD="\$TIMESTAMP.\$PAYLOAD"

HMAC=\$(curl -s -X POST https://api.botoi.com/v1/hash/hmac \\
  -H "Content-Type: application/json" \\
  -d "{
    \\"text\\": \\"\$SIGNED_PAYLOAD\\",
    \\"key\\": \\"test_secret_key\\",
    \\"algorithm\\": \\"sha256\\"
  }" | jq -r '.data.hmac')
echo "HMAC: \$HMAC"

# 3. Send the signed payload to your inbox
curl -s -X POST "\$INBOX_URL" \\
  -H "Content-Type: application/json" \\
  -H "X-Webhook-Signature: \$HMAC" \\
  -H "X-Webhook-Timestamp: \$TIMESTAMP" \\
  -d "\$PAYLOAD"

# 4. Retrieve and inspect the payload
curl -s -X POST "https://api.botoi.com/v1/webhook/inbox/\$INBOX_ID/list" | jq '.data.payloads'

列表端点返回所有收到的有效负载:

[
  {
    "id": "p_abc123",
    "received_at": "2026-04-05T14:30:00Z",
    "body": {
      "id": "evt_001",
      "event": "order.created",
      "amount": 2999
    }
  }
]

此工作流程允许您从命令行测试完整的签名流程。 将收件箱 URL 替换为您的 在 CI 中运行集成测试的生产端点。

Stripe、GitHub 和 Shopify 如何签署 Webhook

提供者 标头 算法 签名的有效负载格式 包含时间戳
条纹 Stripe-Signature HMAC-SHA256 t=UNIX.v1=HEX 是的
GitHub X-Hub-Signature-256 HMAC-SHA256 sha256=HEX
购物 X-Shopify-Hmac-Sha256 HMAC-SHA256 Base64 编码
特威利奥 X-Twilio-Signature HMAC-SHA1 URL + 排序参数

Stripe 是唯一将时间戳与签名捆绑在一起的产品,为您提供开箱即用的重放保护。 对于 GitHub 和 Shopify,添加您自己的时间戳标头或从有效负载正文中检查事件创建时间。

投入生产前的检查清单

  • 将 Webhook 机密存储在环境变量中,而不是源代码中。
  • 解析原始请求正文,而不是 JSON 解析版本。 HMAC 对线路上的确切字节进行签名。
  • 使用 crypto.timingSafeEqual 用于签名比较。 切勿使用 ===
  • 在运行缓慢处理之前返回 200。 将工作排队并确认交付。
  • 记录拒绝的请求(无效签名、过时时间戳、重复)以进行调试和警报。
  • 在幂等性存储上设置 TTL。 72 小时涵盖了大多数提供商的重试窗口。
  • 在连接实时提供商之前,使用一次性收件箱进行测试并计算 HMAC 签名。

FAQ

为什么在查询参数中使用 HMAC-SHA256 而不是共享密钥?
查询参数通过代理和访问日志以明文形式传输。 HMAC-SHA256 使用密钥对整个请求正文进行签名,因此拦截 URL 的攻击者仍然无法伪造有效签名。
为了实现幂等性,我应该将已处理的事件 ID 保留多长时间?
保存至少 24 至 72 小时。 大多数 Webhook 提供程序都会在该窗口内重试。 72 小时后,您可以安全地从商店中删除旧 ID。
我应该使用什么时间戳容差来进行重放保护?
标准为五分钟(300 秒)。 Stripe 使用 300 秒。 较短的窗口可能会导致因网络拥塞而延迟的合法交付被拒绝。
我可以使用 botoi hash/hmac 端点来验证传入的 webhook 吗?
是的。 使用 sha256 算法将有效负载主体和您的秘密发布到 /v1/hash/hmac。 将返回的 HMAC 与来自 Webhook 提供商的签名标头进行比较。
我需要全部三种保护还是可以选择一种?
HMAC 验证是最低限度的。 如果您的 Webhook 处理程序触发费用或电子邮件等副作用,请添加幂等性。 如果您的系统处理金融交易或安全敏感事件,请添加重播保护。

开始使用 botoi 构建

150+ 个 API 端点,涵盖查询、文本处理、图片生成和开发者工具。免费套餐,无需信用卡。