
最近折腾 Stripe Webhook 的重放机制,踩了一个不大不小的坑——签名验证超时。团队接入了死信队列(DLQ),处理支付事件的容错能力是有了,但每次重放积压的事件,SDK 就会抛出一个冷冰冰的错误:
Error: Webhook signature verification failed: Timestamp outside the tolerance zone (1800s > 300s)
查了一圈,发现这是个挺经典的问题,但网上能找到的"解决方案"大多会引入更严重的安全漏洞。这篇把根因说清楚,再给一个靠谱的架构思路。
Stripe 发送 Webhook 时,会在请求头里塞一个 Stripe-Signature,格式大概是:
Stripe-Signature: t=1711234567,v1=5257a869e7eceeda32a1a243a0b7...
这里的 v1 是用你的 Webhook 密钥对时间戳和原始请求体做 HMAC-SHA256 运算的结果:
v1 = HMAC-SHA256(webhook_secret, `${t}.${rawBody}`);
调用 stripe.webhooks.constructEvent(body, sig, secret) 时,SDK 做了两件事:
v1 是否匹配Math.abs(Date.now() / 1000 - t) <= 300,也就是必须在 5 分钟窗口内问题来了:重放攻击和合法的 DLQ 重放在数学上长得一模一样。只要你队列里积压超过 5 分钟,标准验证就会无情拒绝。
有人会加个自定义头 x-replayed: true,有这个头就跳过 constructEvent()。
这个做法等于把大门敞开——任何知道 Webhook 地址的人都可以伪造 customer.subscription.created 事件,配合 x-replayed: true 直接绕过验证,零成本薅你的服务。
// 危险操作:开了 3 天的重放漏洞
stripe.webhooks.constructEvent(body, sig, secret, 86400 * 3);
改成 72 小时确实能让重放通过,但代价是整个支付系统的重放保护全部失效。攻击者可以截获一个支付成功的请求,三天内随时重放,每次都能通过验证。
核心原则是把入口处的验证和内部转发解耦:
[Stripe] │ ▼ (原始 Stripe 签名,时间戳 t=now)
[入口代理] ── 在 300s 窗口内完成签名验证 │ ├─► 立即返回 200 OK 给 Stripe(避免触发重试) │ ▼ (持久化到本地 SQLite / WAL 存储)
[下游调度器] │ ▼ (用新时间戳 t=now + 新 HMAC 重新签名)
[你的业务服务] ── constructEvent() 顺利通过!
具体流程:
t = Math.floor(Date.now() / 1000) 和对应的 v1 签名。重签函数长这样:
const crypto = require('crypto'); function signStripeHeaders(rawBody, headers, secret) { const freshTimestamp = Math.floor(Date.now() / 1000); const freshSignature = crypto .createHmac('sha256', secret) .update(`${freshTimestamp}.${rawBody}`) .digest('hex'); return { ...headers, 'stripe-signature': `t=${freshTimestamp},v1=${freshSignature}` };
}
重放还有个隐患:比如 2 天前的 customer.subscription.deleted 可能晚于今天的 customer.subscription.created 到达,如果直接覆盖状态就乱了。
入口代理可以附加来源元数据头:
x-hookarmor-is-replay: true
x-hookarmor-original-timestamp: 1711234567
x-hookarmor-delivery-id: del_8f92b1c
x-hookarmor-original-stripe-signature: t=1711234567,v1=...
下游服务可以对比 x-hookarmor-original-timestamp 和数据库里这条记录的 updated_at,避免用旧数据覆盖新数据。
由于重放出去的请求带着新鲜的时间戳和有效签名,你的业务 handler 完全是标准写法:
// Next.js / Express 路由里:
export async function POST(req) { const body = await req.text(); const sig = req.headers.get('stripe-signature'); // 首次投递和几天后的重放都能通过: const event = stripe.webhooks.constructEvent( body, sig, process.env.STRIPE_WEBHOOK_SECRET ); await handleBilling(event); return Response.json({ received: true });
}
不需要改一行业务代码,签名验证逻辑全在代理层处理。
如果你想直接用,不想自己造轮子,我把我们这套架构做成了一个开源项目 HookArmor(MIT 协议,单二进制 / Docker 部署):
用 Docker Compose 跑起来:
version: '3.8'
services: hookarmor: image: node:20-alpine working_dir: /app command: sh -c "npm install -g hookarmor && hookarmor start --port 8080 --target http://my-app:3000/webhooks" ports: - "8080:8080" volumes: - ./data:/app/data environment: - HOOKARMOR_TARGET_URL=http://my-app:3000/webhooks - HOOKARMOR_UI_PASSWORD=admin
GitHub 上搜 hookarmor 或者 npm 上直接 npm install -g hookarmor 就能用。
你们的团队现在怎么处理 Webhook 重放和签名过期的问题?评论区聊聊,还有什么其他架构思路。