Stripe 回调遭遇 401,先查平台鉴权
Stripe 回调 Supabase Edge Functions 时,默认 JWT 校验可能在业务代码执行前拒绝请求,表现为 401 且函数日志为空。文章给出关闭该回调函数 JWT 校验的配置思路,并解释 Stripe 使用独立签名机制。
AI 深度解读
Stripe 已经收款,用户却迟迟拿不到 Pro,问题可能出在业务代码执行之前。原文定位了一类 Supabase Edge Functions 故障:默认 JWT 校验先拦住 Stripe 请求,返回 401,函数日志因此空空如也。
修复需要同时处理两件事:关闭 webhook 函数的平台 JWT 校验,并在代码里严格验证 Stripe 签名。这篇最有用的地方,是把入口配置、签名验证和事件重试串成了一条可以照着排查的路径。
收款成功,为什么函数日志还是空的
原文给出的典型症状是:Checkout 正常,Stripe 里能看到付款,但用户权益没有更新。在 Stripe 的 Developers → Webhooks → 对应端点 → Event deliveries 中,每次投递都返回 HTTP 401,响应为 Missing authorization header 或 Invalid JWT。
401 加上空日志,是请求可能被平台提前拦截的线索。
Supabase Edge Functions 默认启用 verify_jwt,在函数执行前检查 Authorization: Bearer <Supabase JWT>。已登录的前端通过 supabase.functions.invoke() 调用时,会携带相应认证信息。
Stripe 没有你应用的登录令牌。它使用 webhook secret 对请求签名,再把签名放进 Stripe-Signature 请求头。两种认证机制对不上,请求就进不了函数。原文称,live mode 下 Stripe 会持续重试,最长可达 3 天,但重试解决不了入口配置错误。
作者认为,Lovable、Bolt 等 AI 构建工具容易生成处理逻辑正确、部署配置却仍为默认值的 webhook。因此,检查生成的代码只是排查的一部分,还得看平台配置和 Stripe 投递记录。
关闭 JWT 校验,要把范围限定到 webhook
原文优先推荐把设置写进 supabase/config.toml,让配置进入仓库并随重新部署保留:
[functions.stripe-webhook]
verify_jwt = false
注意第一行的 stripe-webhook 必须对应实际函数目录名。修改后执行 supabase functions deploy stripe-webhook。
临时也可以运行 supabase functions deploy stripe-webhook --no-verify-jwt。但作者提醒,该参数只作用于本次部署;后续部署若没带参数,配置文件也没有明确设置 false,401 可能再次出现。Dashboard 中也可以进入 Edge Functions → 对应函数 → details,关闭 JWT verification。
如果由 Lovable 或 Bolt 部署,可以明确要求它只添加上述函数配置,并保留 Stripe 签名检查。部署结束后,再去 Dashboard 确认开关状态,最后到 Stripe 对失败事件点击 Resend,检查是否返回 200。
这里不能顺手关闭所有函数的校验。原文要求,像 create-checkout-session 这种供已登录用户调用的函数,通常仍应保留 verify_jwt。
作者另提到 sb_publishable_ 密钥的例外:相关函数如果也出现 401 Invalid JWT,可按其转述的 Supabase 文档关闭平台检查,并在函数内验证用户。原文没有展开具体适用条件,不能把这个例外理解成所有接口都可以取消认证。
签名、幂等和失败响应,要一起补齐
关闭平台 JWT 校验后,知道 URL 的人都能向 webhook 发起 POST。此时若直接相信请求体,攻击者就可能伪造 checkout.session.completed,骗取 Pro 权益。
下面保留原文的签名验证示例,它解决的是 Deno 环境中如何验证请求,再进入事件处理逻辑:
// supabase/functions/stripe-webhook/index.ts
import Stripe from "npm:stripe@^23";
const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY")!, {
httpClient: Stripe.createFetchHttpClient(),
});
const cryptoProvider = Stripe.createSubtleCryptoProvider();
Deno.serve(async (req) => {
if (req.method !== "POST") return new Response("Method not allowed", { status: 405 });
const signature = req.headers.get("stripe-signature");
if (!signature) return new Response("Missing signature", { status: 400 });
const body = await req.text(); // 在任何 JSON 解析之前读取原始文本
let event: Stripe.Event;
try {
event = await stripe.webhooks.constructEventAsync(
body,
signature,
Deno.env.get("STRIPE_WEBHOOK_SECRET")!,
undefined,
cryptoProvider,
);
} catch (err) {
console.error("Stripe signature check failed:", (err as Error).message);
return new Response("Invalid signature", { status: 400 });
}
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
if (session.payment_status === "paid") {
// 在这里授予权限,使用 session.client_reference_id 查找用户。
}
break;
}
// customer.subscription.updated / deleted, charge.refunded, ...
}
return Response.json({ received: true });
});
重点看 await req.text() 和 constructEventAsync:不能先调用 req.json() 再重新序列化,否则内容可能不再匹配签名;Deno 中使用 SubtleCrypto provider 时,同步 constructEvent 会抛出 SubtleCryptoProvider cannot be used in a synchronous context。
密钥也必须匹配当前端点。Stripe CLI 的 stripe listen、test-mode 端点和 live 端点各有自己的 whsec_...。部署环境用 supabase secrets set STRIPE_SECRET_KEY=sk_test_... STRIPE_WEBHOOK_SECRET=whsec_... 设置,别写进代码。
签名通过后还没结束。作者建议把 event.id 存入带唯一约束的表,跳过已处理事件;数据库写入失败必须返回 500,让 Stripe 重试。失败时仍返回 200,会让这次投递被当作成功。
本地验证按顺序执行 supabase functions serve --no-verify-jwt --env-file ./supabase/.env、stripe listen --forward-to http://localhost:54321/functions/v1/stripe-webhook、stripe trigger checkout.session.completed,并把监听命令输出的 secret 放进本地 .env。
还要反向测试:运行 curl -i -X POST https://<project-ref>.supabase.co/functions/v1/stripe-webhook -H "Content-Type: application/json" -d '{"type":"checkout.session.completed"}'。没有签名的请求应返回 400;如果返回 200,就说明伪造事件能被接受。
我的判断
这套排查路径成立的关键,是分清平台认证和支付方签名各自负责什么。把配置写进仓库,也比每次记住部署参数更容易审查和复现。
不过,原文没有展示投递截图、执行日志或对照实验。「AI 构建工具经常踩坑」属于作者的经验判断,没有数据支撑发生频率。空日志同样只是线索,还需要结合响应内容和实际配置确认原因。
代码也是骨架:授权部分只有注释,幂等表和数据库失败处理都没有实现。尤其不能简单地先记录 event.id,再授予权益,否则后一步失败后,重试可能被误判为已处理。原文没有说明这两步如何保持一致,这是落地时要补的部分。
作者另售 $14.98 的 Paywall Kit,并称其中包含完整测试版本;原文没有提供足够证据验证这个声明。免费内容已经能解释入口 401,但还不能当成完整支付系统直接上线。
能拿来做什么
- 排查付款后权益未更新时,先查看 Stripe Event deliveries 的状态码和响应,再检查 Supabase 日志,用两边的信息判断请求卡在哪一层。
- 使用 Lovable、Bolt 生成支付功能时,把函数级
verify_jwt配置纳入代码审查,并在重新部署后确认 Dashboard 的实际状态。 - 给 webhook 保留两类验证:合法 Stripe 事件能完成权益写入,无签名请求返回 400;再检查重复投递和数据库失败是否会造成漏授权或重复授权。