Stripe webhook 401 on Supabase Edge Functions
Guide · October 14, 2026
Checkout looks fine. Stripe's dashboard shows the webhook failing with 401. Your Edge Function logs are empty. That pairing is almost always the platform JWT check, not your Stripe code.
Stripe does not have a Supabase user token. If verify_jwt is on (the default), the request dies before Deno.serve runs. After that is off, you still have to verify Stripe-Signature on the raw body with constructEventAsync.
Sources: Function configuration (Stripe is the documented verify_jwt = false example), Authorization headers, Edge Function 401, Securing Edge Functions (webhook + constructEventAsync), and Stripe signature verification.
What it looks like
Stripe Workbench → the endpoint → failed deliveries. Response body is from the platform, not your return new Response:
{
"code": 401,
"message": "Missing authorization header"
}
Sometimes Invalid JWT / Invalid Token or Protected Header formatting. Same class of failure: verify_jwt ran, your function did not.
Telltale signs:
- Function logs have no invocation for that timestamp (no
execution_id). - The handler works when you call it yourself with
Authorization: Bearer <anon key>. - Local
stripe listenplussupabase functions serve --no-verify-jwtworks, production 401s.
A later, different bug: you turned JWT verification off, now Stripe shows 400 and the body is Webhook signature verification failed. That means your code ran. Jump to the signature section.
Cause: verify_jwt runs first
Every Edge Function request hits two layers (docs):
verify_jwt(default on). The platform inspectsAuthorizationfor a user JWT. Missing, malformed, or the wrong key → 401, handler skipped.- Your code. This is where Stripe's signature belongs.
Stripe POSTs to https://YOUR_PROJECT.supabase.co/functions/v1/YOUR_FUNCTION with Stripe-Signature and a JSON body. No Supabase JWT. Layer 1 always loses unless you disable it for that function.
Supabase's own config docs use a Stripe webhook as the reason to set verify_jwt = false. The auth guide then verifies the provider signature inside the handler with auth: 'none'.
Fix 1: turn off JWT verification
Do this only on the webhook function. Leave user-facing functions on.
Dashboard (fastest in Lovable)
Supabase Dashboard → Edge Functions → the webhook → detail tab → toggle JWT verification off. That is the path in the 401 troubleshooting doc (Method A).
If Lovable redeploys the function later, a dashboard-only toggle can flip back. Put it in config too.
config.toml (sticks across deploys)
In supabase/config.toml, use the function's folder name:
[functions.stripe-webhook]
verify_jwt = false
Redeploy. CLI equivalent: supabase functions deploy stripe-webhook --no-verify-jwt.
Paste this into Lovable if it keeps regenerating the function with JWT on:
The Stripe webhook Edge Function must not require a Supabase JWT. Set verify_jwt = false for that function in supabase/config.toml. Stripe authenticates with Stripe-Signature, not Authorization. Do not disable JWT verification on other functions.
Fix 2: constructEventAsync on the raw body
Once Stripe can reach the handler, verify it is actually Stripe. Deno has no sync Node crypto, so the Stripe SDK's SubtleCrypto provider is async. The official handler uses constructEventAsync, not constructEvent.
Read the body with req.text() first. Signature check is HMAC over those exact bytes. req.json() up front, or JSON.stringify of a parsed object, will fail verification (Stripe: "the raw body of the request").
import Stripe from "npm:stripe";
const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY")!);
const cryptoProvider = Stripe.createSubtleCryptoProvider();
Deno.serve(async function (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
return new Response(JSON.stringify({ received: true }), {
headers: { "Content-Type": "application/json" },
status: 200,
});
});
Same shape in the current Securing Edge Functions webhook example, wrapped with withSupabase({ auth: 'none' }). Either style is fine. The invariants are: JWT off, raw text, constructEventAsync, 400 on a bad signature, 200 after you accept the event.
Return 200 quickly. Stripe retries 4xx/5xx. Do slow work after you have verified, or enqueue it. Do not 401 on a bad signature; that looks like the JWT bug you just fixed.
Secrets
STRIPE_WEBHOOK_SECRETis the endpoint signing secret from Stripe Workbench → Webhooks → Reveal secret. It starts withwhsec_.- The secret printed by
stripe listenis a differentwhsec_. Do not verify Dashboard deliveries with the CLI secret, or the other way around. - Live mode and test mode each have their own secret.
- Store both
STRIPE_SECRET_KEYandSTRIPE_WEBHOOK_SECRETas Edge Function secrets, notVITE_/NEXT_PUBLIC_env in the Lovable client.
Quick checklist
- Read the 401 body.
Missing authorization header/Invalid JWT→ JWT check. Empty body or your own message → your code returned 401. - Set
verify_jwt = falsefor that function only. Redeploy. - Confirm Stripe's URL is
https://YOUR_PROJECT.supabase.co/functions/v1/YOUR_FUNCTION. const body = await req.text(), thenconstructEventAsync(body, signature, secret, undefined, cryptoProvider).- Do not
req.json()before verification. Do not call syncconstructEvent()on Deno. - 400 on a bad signature, 200 when you have accepted the event.
- Use the Dashboard
whsec_in production, the CLI secret only withstripe listen.
Questions
Why does Stripe get 401 on a Supabase Edge Function?
By default Edge Functions run verify_jwt before your code. Stripe does not send a Supabase Authorization header, so the platform returns 401 and the handler never runs. Official fix: verify_jwt = false on that function, then verify Stripe-Signature yourself.
Is a signature verification failure the same as a 401?
No. 401 with Missing authorization header is the JWT gate. A bad Stripe signature should be 400 from constructEventAsync after the request reaches you. Fix JWT first if logs are empty.
Why constructEventAsync instead of constructEvent?
Deno has no synchronous Node crypto. constructEvent() throws SubtleCryptoProvider cannot be used in a synchronous context. Use constructEventAsync with Stripe.createSubtleCryptoProvider().
Can I JSON.parse the body before verifying?
No. Stripe signs the exact bytes it sent. Parsing and re-serializing changes the payload. req.text() first. constructEventAsync already returns the Event object.
Does turning off verify_jwt make the function public?
Yes, anyone can POST to it. That is the point for Stripe. Your handler must verify the signature with the whsec_ secret before it writes to the database. Leave JWT verification on for functions you call with supabase.functions.invoke.
If login is the thing that broke in production, that is a different URL allowlist: preview vs production login. If a webhook "succeeds" and the row still does not change, read RLS silent updates.
Webhook 401 taking down checkout? That is a Stripe fix. $299, you test the PR before you pay. No fix, no pay.
Get this fixed for $299