Checkout works. Stripe's dashboard shows the webhook failing with 401. Your Edge Function logs are empty for that timestamp. That combo is almost never your Stripe code. It's the Supabase JWT check running before your handler.
What it looks like
In Stripe Workbench, open the endpoint and look at a failed delivery. The response body comes from the platform, not from your return new Response(...):
{
"code": 401,
"message": "Missing authorization header"
}
Sometimes it's Invalid JWT instead. Same thing. Other tells:
- No invocation in the function logs for that delivery.
- The function works when you call it yourself with
Authorization: Bearer <anon key>. - Local
stripe listen+supabase functions serve --no-verify-jwtworks, production 401s.
Why it happens
Every Edge Function request goes through two layers:
-
verify_jwt(on by default). The platform wants a Supabase JWT inAuthorization. Missing or wrong means 401 and your code never runs. - Your handler. This is where Stripe's signature check belongs.
Stripe sends Stripe-Signature and a JSON body. It has no Supabase token, so layer 1 always rejects it. Supabase's own function config docs use a Stripe webhook as the example for turning this off.
Fix 1: turn off JWT verification for that one function
In supabase/config.toml (use the function's folder name):
[functions.stripe-webhook]
verify_jwt = false
Redeploy, or supabase functions deploy stripe-webhook --no-verify-jwt. You can also flip the toggle in the dashboard, but if Lovable redeploys the function, a dashboard-only toggle can come back on. Put it in config so it sticks. Leave JWT verification on for every user-facing function.
Fix 2: verify the signature on the raw body
Now anyone can POST to that URL, so the handler has to prove it's Stripe. Two gotchas on Deno:
- Read the body with
req.text()first. Stripe's docs: "Stripe requires the raw body of the request to perform signature verification."req.json()before verifying will break it. - Use
constructEventAsync, notconstructEvent. Deno's crypto is async.
import Stripe from "npm:stripe";
const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY")!);
const cryptoProvider = Stripe.createSubtleCryptoProvider();
Deno.serve(async (req) => {
const signature = req.headers.get("Stripe-Signature") ?? "";
const body = await req.text();
let event;
try {
event = await stripe.webhooks.constructEventAsync(
body,
signature,
Deno.env.get("STRIPE_WEBHOOK_SECRET")!,
undefined,
cryptoProvider
);
} catch (err) {
console.error("Stripe signature verification failed:", err);
return new Response("bad signature", { status: 400 });
}
// handle event.type, then acknowledge fast
return new Response(JSON.stringify({ received: true }), {
headers: { "Content-Type": "application/json" },
status: 200,
});
});
Return 400 on a bad signature (not 401, or you'll think the JWT bug is back) and 200 quickly once you've accepted the event.
The secret mix-up
- Production deliveries are signed with the endpoint secret from Stripe Workbench (starts with
whsec_). -
stripe listenprints a differentwhsec_. Don't mix them. - Test mode and live mode each have their own.
- Keep
STRIPE_SECRET_KEYandSTRIPE_WEBHOOK_SECRETin Edge Function secrets, never inVITE_client env.
Checklist
- 401 body says
Missing authorization headerorInvalid JWT? JWT gate. Setverify_jwt = falseon that function only. -
req.text(), thenconstructEventAsync(...). - 400 on bad signature, 200 when accepted.
- Dashboard
whsec_in prod, CLI secret only withstripe listen.
Full guide
Longer version with source links and an FAQ ("does turning off verify_jwt make it public?", constructEvent vs constructEventAsync):
https://fixloop.dev/guides/stripe-webhook-401-supabase-edge-functions
If the webhook "succeeds" but the row never changes, that's usually RLS silently blocking the update, not Stripe. If it's login that broke after deploy, I wrote up the 3 redirect lists.
I run FixLoop. There's a free scan for leaked keys and missing headers, and we fix broken Lovable/Supabase apps for a fixed price. No fix, no pay.
Top comments (1)
The empty function log is the diagnostic gift here — if your handler never ran, debugging your handler is wasted time, and that one observation should move the search up a layer to the platform in front of it. The local-versus-production split makes it worse: a local setup that disables the check by flag will pass every test while guaranteeing the production failure. Matching the local environment's auth behaviour to production, even when it is inconvenient, is what turns this from a surprise into a test case.