Stripe webhook 本地能过,部署到服务器就验签失败,会是时钟的问题吗?

本地用 stripe listen 转发时一切正常,同一份代码部署上线,验签却一个都过不去。从本机挪到服务器,中间真正换掉的东西没几样:请求多经过了一层反向代理或网关,框架的中间件配置可能不一样,签名密钥换成了线上端点那个,时钟也换成了另一台机器的。大家往往先怀疑时钟,可按 Stripe 排错页的写法,时钟排不到第一位。

密钥拿对了没有

Stripe 的 webhook 排错页把用错签名密钥列为最常见的原因。stripe listen 在终端里打印的密钥,和 Workbench 里那个已注册端点的密钥不是同一个。同一个端点在测试和正式环境下,密钥也不一样。几种密钥都以 whsec_ 开头,扫一眼很难分辨。把代码实际读到的值打出前几位,跟 Workbench 的 Webhooks 页里点 Reveal secret 看到的对一下,环境变量没切过来的情况一眼就能看出来。

请求体有没有被动过

签名是拿端点密钥,对「时间戳 + . + 请求体原文」这串字符算的 HMAC-SHA256。请求体多一个空格、键的顺序变了,结果就对不上。官方文档反复强调验签要用原始请求体:框架先把 JSON 解析成对象,你再序列化回字符串交给验签,那已经不是 Stripe 发来的那串了。

排错页点名的几个位置:

  • Express 里 app.use(express.json()) 要放在 webhook 路由后面,放在前面的话,请求体会先被解析掉。
  • Next.js 的 Pages Router 要关掉 bodyParser,自己用 buffer 读原始内容。
  • AWS API Gateway 转 Lambda 的,文档给了一个映射模板,把原始请求体放进 rawBody 再交给函数。

本地能过、线上不行,还要看线上是不是多了一层东西,比如网关统一挂了 JSON 处理、改了编码。另外 Rails、Django 这类框架默认带 CSRF 校验,可能让 webhook 请求根本走不到验签那一步。Stripe 文档建议给这个路由单独豁免。

Header 有没有被改写

把收到的 Stripe-Signature 原样写进日志。正常的值是一整行,格式类似 t=xxx,v1=yyy,v0=zzz,其中 v0 只在测试事件里出现,正式环境只认 v1。拿到的值是空的、被截断了,或者读 header 的代码取错了字段,问题多半出在代理配置或取值逻辑上,跟签名算法无关。

再说时钟

t= 后面是 Stripe 发出这次请求时的时间戳。这个时间戳本身也算在签名里,中途改不了。webhook 文档里写着,官方库默认允许时间戳和服务器当前时间相差 5 分钟,超出就当作过期拒掉,用来防重放攻击。服务器时钟偏差超过这个范围,签名算得再对也通不过。文档的建议是用 NTP 保证服务器时间准确,跟 Stripe 那边同步。排查时可以在日志里同时记下 t 和服务器当前的 Unix 时间,一减就知道偏了多少。

容差能在验签时多传一个参数调大,但文档明确说不要设成 0,设成 0 等于把时效检查整个关掉。时钟不准就去修时钟,别靠放宽容差糊过去。

还有一个和时间有关的坑:Stripe 每次投递,包括重试,都会重新生成时间戳和签名。从日志里抠出一份旧请求、用 curl 重放到线上测试,过了 5 分钟肯定验不过,这说明不了代码有问题。想复现的话,在 Dashboard 里对具体事件点 Resend,或者用 stripe trigger 造一个新事件更靠谱。

站里之前那篇护照加港卡能不能开 Stripe聊的是开户资质,跟这里的接入排错是两回事。另一篇支付宝异步通知验签失败讲的是别的支付渠道,排查时可以对照着看。

你们线上碰到的是哪一种?