「Stripe webhook signature verification failed」表示:你的服务器根据入站请求体和 Webhook 签名密钥计算出的 HMAC 签名,与 Stripe 在 Stripe-Signature 头中附带的签名不一致。在绝大多数情况下,到达验证代码的载荷字节、签名密钥或头字段值与 Stripe 最初发送的不一致。本指南梳理所有常见原因,并给出可靠的排查顺序,让你在几分钟内定位问题,而不是耗费数小时。
在 Node.js 中该错误表现为 StripeSignatureVerificationError,在 Python 中为 SignatureVerificationError,其他 Stripe SDK 也有类似异常。在开始修改应用代码之前,值得先弄清验证函数使用的三个精确输入——以及究竟是哪一个出了问题。
这个错误真正意味着什么
Stripe 使用 HMAC-SHA256 签名保护 Webhook 投递。当 Stripe 向你的端点发送事件时,会在其侧执行三步:
- 取请求的原始 JSON 正文。
- 在前面加上时间戳:
{timestamp}.{rawBody}。 - 计算
HMAC-SHA256(signing_secret, timestampedPayload),并将结果以v1签名的形式放入Stripe-Signature头。
你的服务器用本地保存的签名密钥重复相同计算。若两个签名一致,则请求真实且未被篡改;若不一致,SDK 会抛出签名验证错误。
因此只有三类原因会导致该错误:
- 代码使用的签名密钥错误。
- 传给验证函数的原始正文与 Stripe 发送的不一致。
Stripe-Signature头在代码读取之前被改动或丢失。
下面每一步排查都对应上述三类输入之一。
最常见原因
1. Webhook 密钥错误
这是最高频的原因。Stripe 为你在控制台注册的每个 Webhook 端点生成唯一的 whsec_... 密钥。若开发/生产各有一个端点,或测试模式与正式模式各有一套,则每套密钥都不同。
常见失误:
- 从 Stripe 控制台里错误的端点行复制了密钥。
- 误用 Stripe API 密钥(
sk_test_...或sk_live_...)而非 Webhook 签名密钥。 - 在控制台轮换密钥后,环境里仍是旧值。
- 修改了
.env但进程未重启,未加载新配置。
打开 Stripe 控制台 > Developers > Webhooks,点击与当前 URL 匹配的端点,显示签名密钥。与应用程序运行时读取的值逐字符比对。
2. 验证前原始正文被改动
Stripe 对 HTTP 请求体的确切字节签名。若在验证函数运行前有任何环节改变了这些字节,计算出的签名就会不同。常见改动包括:
- JSON 解析器先把正文解析成对象再重新序列化。即使语义相同,键顺序、空白或 Unicode 转义也可能不同。
- 反向代理或 API 网关对正文解压、重编码或规范化。
- 平台做了字符编码转换(例如 Latin-1 到 UTF-8)。
修复原则始终一致:向验证函数传入线上到达的原始字节,且在任何处理之前。
3. 中间件过早解析了正文
这是 Node 应用里原始正文问题最常见的变体。在 Express 中若全局使用 app.use(express.json()),会先解析所有入站正文。等 Webhook 路由执行时,req.body 已是对象。把 JSON.stringify(req.body) 传给 stripe.webhooks.constructEvent() 得到的字节与原始载荷不同。
Next.js API 路由(默认会解析正文)、Fastify(内置 JSON 解析)以及多数 Serverless 平台也会出现同类问题。各框架的解决办法见下文「按框架修复」一节。
4. 测试/正式模式混用
Stripe 的测试模式与正式模式完全隔离,各有自己的 Webhook 端点、签名密钥与事件流。若在测试模式注册了端点,但代码用的是正式模式的密钥(或相反),验证将永远失败。
请核对:
- 在 Stripe 控制台查看该端点时,右上角的模式切换。
- 密钥前缀:
whsec_...不表示模式,必须确认它属于正确端点。 - 事件数据:测试模式事件使用
evt_test_...等测试 ID。
5. Stripe-Signature 头缺失或错误
部分基础设施会剥离或重命名非标准 HTTP 头。负载均衡、CDN 边缘函数、API 网关是常见来源。若 Stripe-Signature 在到达代码前缺失或被截断,验证无法成功。
请确认:
- 反向代理或负载均衡转发所有头。Nginx 中确认
proxy_pass_request_headers为 on(默认)。 - 应用栈中没有任何中间件丢弃或重命名该头。
- 按框架约定读取头名。HTTP 规范不区分大小写,但部分框架以固定形式暴露(例如 Express 中用
request.headers['stripe-signature'])。
6. 实际命中的端点与预期不符
若在 Stripe 配置了多个 Webhook 端点(例如开发、预发、生产各一),请求可能到达某 URL,而处理程序却用了另一个端点的密钥。常见于:
- URL 迁移后旧端点仍启用。
- 使用 Stripe CLI 转发的开发端点与已部署端点重叠。
- 通配路由把请求交给了错误处理器。
在 Stripe 控制台审计活跃端点列表,禁用不再需要的端点。
分步排查流程
第一步 —— 确认请求已到达
在排查签名之前,先确认请求真的到了你的服务器。若从未到达,问题是投递而非验证。可使用 Webhook 调试器 在应用之外独立捕获入站请求,从而获得未被中间件污染的干净副本(含头与正文)。
若在本地测试,请确认隧道(ngrok、Cloudflare Tunnel 或 Stripe CLI)在运行,且转发到正确的本地端口。
第二步 —— 检查头与原始载荷
查看到达服务器时的 Stripe-Signature 与完整请求体。在任何处理之前记录它们:
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['stripe-signature'];
const rawBody = req.body; // Buffer
console.log('Stripe-Signature:', sig);
console.log('Raw body length:', rawBody.length);
console.log('Raw body preview:', rawBody.toString('utf8').substring(0, 200));
// proceed with verification...
});
若 Stripe-Signature 缺失,或正文是对象而非 Buffer,问题已定位。
第三步 —— 再次核对 Webhook 密钥
打开 Stripe 控制台 > Developers > Webhooks,找到与接收请求的 URL 匹配的端点,显示签名密钥,与应用程序读取的值比对:
// Log a safe prefix to confirm the right secret is loaded
console.log('Webhook secret prefix:', process.env.STRIPE_WEBHOOK_SECRET?.substring(0, 12));
确认环境变量已正确加载。在 Docker 与 Serverless 中,构建期与运行期的环境变量可能不一致。
第四步 —— 审查框架的请求解析
检查框架是否在 Webhook 处理函数运行前自动解析了请求体。
Express 示例 —— 调整 express.json(),使其不作用于 Webhook 路由:
const express = require('express');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const app = express();
// Webhook route MUST come before express.json() middleware
app.post(
'/webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
const sig = req.headers['stripe-signature'];
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
console.error('Signature verification failed:', err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// Handle the event
switch (event.type) {
case 'checkout.session.completed':
// Fulfill the order
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
res.json({ received: true });
}
);
// JSON parsing for all other routes
app.use(express.json());
app.listen(3000, () => console.log('Server running on port 3000'));
Next.js App Router 示例 —— 从请求读取原始正文:
// app/api/webhook/route.ts
import Stripe from 'stripe';
import { NextRequest, NextResponse } from 'next/server';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(request: NextRequest) {
const sig = request.headers.get('stripe-signature')!;
const rawBody = await request.text();
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
rawBody,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err: any) {
console.error('Signature verification failed:', err.message);
return NextResponse.json(
{ error: `Webhook Error: ${err.message}` },
{ status: 400 }
);
}
switch (event.type) {
case 'checkout.session.completed':
const session = event.data.object;
// Fulfill the order using session data
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
return NextResponse.json({ received: true });
}
第五步 —— 用已保存请求重试
修复后,应重放此前失败的那次请求,而不是在 Stripe 里重新触发新事件,以确认修复对同一载荷有效。可使用 Stripe 控制台或 Webhook 调试工具中保存的请求数据,按 在本地重放 Stripe Webhook 事件 操作。
重放还能节省时间:不必每次为验证修复都重新走一遍下单、结账等测试场景。
按框架修复
Express
关键是让 Webhook 路由收到原始 Buffer。在该路由上使用 express.raw(),并把它放在全局 express.json() 之前:
const app = express();
// Option A: Route-level raw body (recommended)
app.post('/webhook', express.raw({ type: 'application/json' }), handleWebhook);
// Option B: Global JSON parsing with raw body preserved via verify callback
app.use(
express.json({
verify: (req, res, buf) => {
req.rawBody = buf;
},
})
);
方案 A 更清晰:Webhook 路由永远不会看到已解析的正文。方案 B 适合同一路由既要解析 JSON 又要保留原始字节。
Next.js
在 Pages Router 中,为 Webhook API 路由关闭正文解析:
// pages/api/webhook.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import { buffer } from 'micro';
import Stripe from 'stripe';
export const config = {
api: {
bodyParser: false,
},
};
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const sig = req.headers['stripe-signature'] as string;
const rawBody = await buffer(req);
try {
const event = stripe.webhooks.constructEvent(
rawBody,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
// Handle event...
res.status(200).json({ received: true });
} catch (err: any) {
res.status(400).send(`Webhook Error: ${err.message}`);
}
}
在 App Router(Next.js 13+)中,用 request.text() 获取原始正文,见上文第四步示例。使用 .text() 读取时,App Router 不会自动解析正文。
Python / Flask
Flask 通过 request.data 或 request.get_data() 提供原始正文。务必在访问 request.json(会触发解析)之前调用:
import stripe
from flask import Flask, request, jsonify
app = Flask(__name__)
stripe.api_key = os.environ['STRIPE_SECRET_KEY']
@app.route('/webhook', methods=['POST'])
def webhook():
sig = request.headers.get('Stripe-Signature')
raw_body = request.get_data()
try:
event = stripe.Webhook.construct_event(
raw_body, sig, os.environ['STRIPE_WEBHOOK_SECRET']
)
except stripe.error.SignatureVerificationError:
return jsonify({'error': 'Invalid signature'}), 400
# Handle event...
return jsonify({'received': True}), 200
在 Django 中可使用 request.body,在任何中间件处理之前即为原始字节。
快速检查清单
按清单逐项排除:
- 请求确实到达了端点(日志或 Webhook 调试器 可确认)
-
Stripe-Signature头存在、完整且未被截断 - Webhook 签名密钥与 Stripe 控制台中该端点、该模式(测试/正式)完全一致
- 向验证函数传入的是未解析的原始正文(Buffer 或 bytes)
- 没有全局中间件(如
express.json())在 Webhook 路由之前解析正文 - 测试模式与正式模式在端点注册与密钥之间没有混用
- Stripe 控制台中的 URL 与服务器实际监听的 URL 完全一致
- 修复后已 重放同一失败请求 验证修复有效
常见问题
为什么只有本地开发环境签名验证失败?
本地与生产通常在多处不同:.env 里的密钥可能不同;若用 Stripe CLI 转发,CLI 会生成自己的 whsec_...(在 CLI 输出中可见),与控制台密钥不同,本地须用 CLI 提供的密钥。此外,本地隧道工具有时会改动头或重编码正文。
开发阶段可以跳过签名验证吗?
技术上可以,但强烈建议始终开启验证。跳过会使开发环境与生产不一致,掩盖 bug。若本地验证太麻烦,可用 Stripe CLI 转发及其提供的签名密钥——无需把服务器暴露到公网也能走通验证流程。
昨天还能验证,今天为什么失败?
立刻检查三件事:Webhook 密钥是否在控制台被轮换(Stripe 允许轮换,宽限期过后旧密钥失效);新部署是否改了中间件或正文解析;是否误在测试/正式模式间切换。另查依赖升级是否改变了框架对请求体的处理方式。
Stripe-Signature 头会过期吗?
会。Stripe 在签名头中包含时间戳,SDK 默认会拒绝过旧的签名(常见容差约 300 秒/5 分钟)。若请求在队列中积压、处理严重延迟,即使 HMAC 正确也可能因时间戳检查失败。可通过 tolerance 参数放宽,但会削弱重放攻击防护;更好做法是立即验证签名,再异步处理业务逻辑。
轮换 Webhook 密钥时如何避免停机?
在 Stripe 轮换签名密钥后,会有一段新旧密钥同时有效的窗口。在此期间可让应用优先用新密钥验证,失败则回退旧密钥;宽限期结束后再移除旧密钥。部分团队用两个环境变量(STRIPE_WEBHOOK_SECRET 与 STRIPE_WEBHOOK_SECRET_OLD)按顺序尝试。
下一步
签名验证正常后,可继续搭建可靠的 Webhook 处理:
- 建立完整的 Stripe Webhook 本地测试 流程,在上线前发现验证问题。
- 学习 在本地重放 Stripe Webhook 事件,用真实载荷调试而无需反复触发新事件。
- 阅读完整的 Stripe 集成指南,涵盖端点创建、事件筛选与监控。
- 使用 Webhook 调试器 检查入站请求、对比头与正文,并诊断各提供商的投递问题。
- 浏览更多 Stripe Webhook 指南,涵盖测试、签名验证与事件重放等主题。