Webhook 安全性:HMAC 签名、幂等性和重放保护
三种代码模式可阻止欺骗性负载、重复传送和重播 Webhook 请求。 具有 HMAC-SHA256 和时间戳检查的 Node.js 示例。
您的 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 提供商的签名标头进行比较。
端点同时支持 sha256 和 sha512 算法。
构建用于 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 端点,涵盖查询、文本处理、图片生成和开发者工具。免费套餐,无需信用卡。