代码 Stripe webhook 一直 400 signature verification failed:先查 raw body 有没有被 JSON 中间件吃掉

2026-10-18 09:01:05

Stripe webhook 验签 400:raw body、whsec_ 与 300 秒容差逐项排查

服务端日志报出:

Webhook signature verification failed. Err: No signatures found matching the expected signature for payload.

接口返回 400。Stripe Dashboard 的 Event deliveries 里出现大量 400 记录,随后 Stripe 开始按退避策略重试。

先区分两种情况。“没收到 webhook”通常指两类问题:a) 请求根本没有到达(防火墙拦截、endpoint 未启用、事件类型未订阅);b) 请求到达了,但验签返回 400,handler 里的业务逻辑没有执行。判断依据是 Event deliveries 的状态码:没有投递记录,说明属于 a;有记录且状态码为 400,说明属于 b。下面只讨论 b。

签名到底签了什么

官方文档给出的 Stripe-Signature 头格式是:

t=xxx,v1=yyy,v0=zzz

签名原文是 {t}.{raw_body},用 endpoint 的 whsec_ 密钥做 HMAC-SHA256,输出 hex。头里可能有多个 v1,这是 secret 轮换期的候选签名,任意一个匹配即通过。

不依赖 SDK 的 Node 手写验证:

import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 300;
export function verifyStripe(secret, header, rawBody) {
let timestamp = null; const sigs = [];
for (const item of String(header ?? '').split(',')) {
const [k, v] = item.trim().split('=', 2);
if (k === 't') timestamp = Number(v);
if (k === 'v1' && v) sigs.push(v);
}
if (!Number.isInteger(timestamp) || sigs.length === 0) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
const expected = createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest('hex');
const a = Buffer.from(expected, 'hex');
return sigs.some((s) => { const b = Buffer.from(s, 'hex'); return a.length === b.length && timingSafeEqual(a, b); });
}

SDK 一行等价:stripe.webhooks.constructEvent(rawBody, sigHeader, endpointSecret)。Go 侧是 stripe.Webhook.ConstructEvent,PHP 是 \Stripe\Webhook::constructEvent。新版 SDK 另有 parseEventNotification 处理 thin event。

坑 1:raw body 被框架改过

官方文档明确要求:必须传入 Stripe 发来的原始 UTF-8 字节;任何解析、重排、去空格、改编码都会导致验签失败。这是实际排查中最常见的一类。

  • Express:app.use(express.json()) 若注册在 webhook 路由之前,body 已经被解析成对象。webhook 路由必须放在 JSON 中间件之前,或用 express.raw({ type: 'application/json' }) 单独拿 buffer。
  • Next.js Pages Router:需要 export const config = { api: { bodyParser: false } },再用 buffer(req) 读取。
  • Next.js App Router:用 await req.text(),不要用 req.json()。
  • Laravel/PHP:必须用 $request->getContent()。如果对 $request->all() 的结果再 json_encode,一定会失败,因为键序、转义和 unicode 都可能变化。
  • Go:io.ReadAll(r.Body) 只能读一次,读完不能重读;Gin 用 c.GetRawData();如果中间件先把 body 绑定到结构体,Body 已经被消费。

可验证的做法:在 handler 里对 raw body 打 sha256,与 Event deliveries 详情里的 payload 长度和内容对照;或先把 raw body 原样写入磁盘或日志,再用同一 secret 离线计算一遍比对。

坑 2:secret 用错

两套 secret 的前缀都是 whsec_,肉眼很难分辨:

  • stripe listen 转发到本地时打印的 secret,与 Dashboard/Workbench 里 endpoint 的 secret 不同;
  • 测试模式与现网模式的 endpoint secret 不同;
  • 同一个账户可以有多个 endpoint(上限 16),每个 endpoint 各有自己的 secret。

排查时,把代码实际使用的 endpointSecret 前 8 位和长度打进日志,与 Workbench 里 Reveal secret 或 stripe listen 的输出对照。不要打印全量密钥。

坑 3:时钟偏移与容差

官方 SDK 默认容差 5 分钟(300 秒)。签名里的 t 参与验签,所以改时间戳会直接破坏签名;但服务器时钟偏移超过容差,合法请求也会被拒。官方明确不建议把 tolerance 设为 0,那相当于直接关掉新鲜度检查。

可验证:用 date -u 与 NTP 源比对;systemd 系统看 timedatectl。容器环境容易继承宿主机的时钟漂移。

坑 4:代理/网关重新编码

Nginx、CDN、WAF、API 网关只要对 body 做了解压重压、字符集转换、大小写改写,验签就会失败。判断方法是:把同一事件在 Event deliveries 里的 payload 与写入日志的 body 做 diff,比较长度和首尾字节。处理方式是让 webhook 路径绕过所有 body 处理逻辑。

验签通过之后:重复、顺序、重试

  • 幂等键是事件 id(evt_ 开头)。重试、手动 resend、同一 endpoint 多次投递,带的都是同一个 id。
  • 幂等写法:INSERT ... ON DUPLICATE KEY UPDATE 或 ON CONFLICT DO NOTHING,affected rows=0 就跳过并返回 200。不要先 SELECT 再 INSERT,并发下会双发。
  • 顺序不保证。created 只有秒级精度,两个事件可能同值,别用时间排序推断先后;业务状态机用终态判断(例如已 paid 就不再回退到 pending),或读 data.object 里的状态字段。
  • 重试:现网最多 3 天,指数退避;沙箱 3 次、几小时内。3xx 也算失败,2xx 才算成功。重试次数不可配置。
  • 每次重试都会用新的 t 和签名,所以第三天到达的合法重试仍能通过 5 分钟容差。
  • 手动重投:Dashboard 的 Resend 支持事件创建后 15 天内;stripe events resend --webhook-endpoint= 支持 30 天内。手动重投成功后不会取消自动重试,必须按 id 去重并返回 200,才能停止重复投递。
  • secret 轮换:两个 secret 可同时生效最长 24 小时,所以头里会出现多个 v1。

处理顺序建议

  1. 验签。失败直接返回 400,不要把事件写进业务表。
  2. 幂等插入事件表。插入失败或判定为重复,返回 200。
  3. 立即返回 200,业务逻辑放到队列异步处理;不要在 webhook 里同步调用慢接口。
  4. 业务失败不要指望“返回 500 让 Stripe 重试”兜底超过 3 天的场景,要自己做重放或对账。

不适用场景

  • 国内微信支付/支付宝:验签是平台证书加 SHA256-RSA,回调用 XML 或 AES-GCM 加密 resource,重试节奏 15s 起步、累计 24h4m。不要照搬 Stripe 的 5 分钟容差与 3 天窗口。
  • 只做前端支付确认、不发 webhook 的场景:前端 success 永远不能作为发货依据。

未实测说明

上面的 Node 验签代码是按官方文档的签名规则手写的,只用构造的合法/非法 header 做过单测思路验证,没有在真实 Stripe 账户跑通全链路;重试窗口的数字来自官方文档与公开汇总,未逐条实测。

参考链接:

复制全文 生成海报 Stripe Webhook 支付接口 验签 幂等

推荐文章

程序员茄子在线接单