代码 Stripe Webhook 验签失败:请求体、密钥与环境三类原因的排查顺序

2026-09-15 09:00:47

Stripe Webhook 验签失败:请求体、密钥与环境三类原因的排查顺序

典型报错是这一条:

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

官方文档(Resolve webhook signature verification errors)的说法很直接:constructEvent(requestBody, signature, endpointSecret) 三个参数任意一个不对,都会抛出同一个错误。所以别盯着报错文本猜,要按参数逐个排除。

下文标注 官方口径 的来自 docs.stripe.com,标注 社区经验 的来自第三方整理(fixerror.dev、errormedic.com、axonbuild.com、cesarayala.dev),这些框架写法本次未逐框架实测。

验签在做什么

官方口径:Stripe 用 Stripe-Signature 请求头传递签名,头里的值形如 t=xxx,v1=yyy,v0=zzz;如果不是这个形状,说明从 header 里取签名的代码就有问题。

社区经验:签名是 HMAC-SHA256(时间戳 + '.' + 原始 body),密钥为端点签名密钥。SDK 用收到的 body 重算一遍,逐字节相等才算通过。也就是说三种输入——原始 body、Stripe-Signature 头、endpoint secret——任何一个字节有偏差都会失败。默认时间容差 300 秒(5 分钟),超出报 Timestamp outside the tolerance zone,常见于没有 NTP 同步的容器时钟漂移;这个容差本身是防重放用的。

原因一:endpoint secret 用错(最常见)

官方口径:每个 webhook 端点有自己的签名密钥,前缀都是 whsec_。Dashboard 里创建的端点和 Stripe CLI stripe listen 打印出来的密钥不是同一个——CLI 转发的请求要用 CLI 打印的密钥,Dashboard 端点的请求要用 Dashboard 的密钥。同理,test 与 live 也是各一套密钥。

社区经验:因为前缀都一样,光看 whsec_ 区分不出来源。最常见的坑是一个 STRIPE_WEBHOOK_SECRET 在 local/staging/prod 三处共用,而三个环境各有一个正确值,注定有两个环境失败。

排查方式:把代码里实际传入的 endpointSecret 打印出来,和 Dashboard 上该端点的「Reveal secret」逐字符比对。另外两个快速判断——本地 stripe listen 通过、生产失败,多半是密钥不匹配;所有事件都以同一种方式失败,则更该怀疑原始 body 的处理而不是密钥。

原因二:原始请求体被中间件解析后重序列化

官方口径:body 必须是 Stripe 发来的 UTF-8 字符串原样。框架加删空白、改键顺序、转成 JSON 对象、改编码,都会导致验签失败。

Express 的做法是给 webhook 路由单独挂 express.raw,且 express.json() 必须注册在 webhook 路由之后(中间件按注册顺序执行,json() 在前会先把 body 解析掉):

app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), handler);

// 必须在 webhook 路由之后
app.use(express.json());

其余框架取原始 body 的写法(官方口径 覆盖 Express、Next.js、AWS;其余为 社区经验):

运行时取原始 body
Next.js App Routerconst body = await req.text(),不要用 request.json()
Next.js Pages Routerexport const config = { api: { bodyParser: false } },再用 buffer 读
FastAPIpayload = await request.body(),返回 bytes,该端点别定义 Pydantic 模型
Djangopayload = request.body
Go net/httpbody, err := io.ReadAll(r.Body)
Astro同 App Router,await request.text()
AWS API Gateway + Lambdaapplication/json 配 Body Mapping Template,把 rawBody 传进函数

社区经验 的分诊动作:在 constructEvent 之前打印 typeof body / Buffer.isBuffer(body),应该是 Buffer 或 string,而不是 object。

原因三:代理/CDN 改写 body,或时钟超出容差

社区经验:Cloudflare、nginx、AWS API Gateway、service worker 都可能给 body 加删结尾换行、统一换行符、动 UTF-8 BOM,这些都会让签名失效。如果上游有网关或请求日志中间件,确认它没有对 body 做重写。

时钟问题则看报错:Timestamp outside the tolerance zone 说明服务端时间漂移超过 300 秒。另外,如果验证逻辑是手写的,记得自己处理容差,SDK 里已经内置。

多密钥轮换

社区经验constructEvent 可以接收密钥数组,轮换期间逐个尝试,第一个验过即用,这样不必在切换密钥时停机。

报错串对照

官方/库源码口径

  • Node:No signatures found matching the expected signature for payload.;如果传进去的是已解析的对象,会报 Webhook payload must be provided as a string or a Buffer ... Payload was provided as a parsed JavaScript object instead.;body 没传则报 No webhook payload was provided.
  • Go 库:webhook had no valid signaturewebhook has no Stripe-Signature headerwebhook has invalid Stripe-Signature headertimestamp wasn't within tolerance

先用 Dashboard 的 Recent deliveries 分诊

社区经验:Endpoints → 某个端点 → Recent deliveries,三种情况指向不同的地方:

  • 已送达且返回 200,但业务侧没反应:不是验签问题,问题在 200 之后的处理链路。
  • 非 2xx 且反复重试:端点拒绝、超时或处理出错;单看失败记录分不清是验签还是下游错误。
  • 日志里什么都没有:事件根本没发到这个端点,或者发了没到——模式不匹配(test/live)、没订阅该事件类型、DNS、防火墙、URL 写错、CLI 没转发,都有可能。

验签通过之后仍要做的事

社区经验:Stripe 是至少一次投递,验签通过不代表只收到一次,仍需按 event.id 幂等去重;处理逻辑要尽快返回 2xx,重活丢给异步 worker。Stripe 会对失败投递重试,持续验签失败意味着支付/订阅事件在重试耗尽后被静默丢弃,建议监控 signature_verification_failed 指标。

本地调试:

stripe listen --forward-to localhost:3000/api/webhooks/stripe
stripe trigger payment_intent.succeeded

stripe listen 打印出来的 whsec_ 写进本地 .env

官方文档

  • 验签排障:https://docs.stripe.com/webhooks/signature
  • Webhooks 总览:https://docs.stripe.com/webhooks
复制全文 生成海报 Stripe Webhook 验签 支付回调 排障

推荐文章

程序员茄子在线接单