You connected Stripe to your Lovable, Bolt or hand-written Supabase app. Checkout works and the payment shows up in Stripe. But the user never gets Pro.
In Stripe → Developers → Webhooks → your endpoint → Event deliveries, every attempt failed with HTTP 401 and a response body that says Missing authorization header (or Invalid JWT). And your Edge Function logs are empty.
Empty logs are the clue. Your code never ran.
Why it happens
Supabase Edge Functions run a platform check called verify_jwt before your function. It's on by default. It expects an Authorization: Bearer <Supabase JWT> header, which your own frontend sends automatically when it calls supabase.functions.invoke().
Stripe doesn't have a Supabase login token. It signs each request with your webhook secret and puts that signature in the Stripe-Signature header. So the platform turns Stripe away with a 401, and Stripe keeps retrying (for up to 3 days in live mode).
AI builders hit this a lot because they generate a perfectly good webhook function, deploy it with the default settings, and never look at Stripe's delivery log.
The fix: turn off the JWT check for the webhook function only
Pick one of these.
A. supabase/config.toml (best, because it stays in your repo and survives redeploys)
[functions.stripe-webhook]
verify_jwt = false
Use your function's folder name if it isn't stripe-webhook. Then redeploy:
supabase functions deploy stripe-webhook
B. A flag at deploy time
supabase functions deploy stripe-webhook --no-verify-jwt
The flag only applies to that deploy. If a later deploy runs without it and config.toml doesn't say false, the 401s come back. That's why A is better.
C. Dashboard
Supabase Dashboard → Edge Functions → your webhook function → details → switch JWT verification off.
If Lovable or Bolt deploys your functions for you, ask it to add the config.toml block above, for example:
Add
[functions.stripe-webhook]withverify_jwt = falseto supabase/config.toml. Don't change any other function's setting. Keep the Stripe signature check in the webhook.
After it redeploys, open the function in the Supabase Dashboard and confirm JWT verification really is off.
Then in Stripe, open one of the failed events and click Resend. You should get a 200.
Leave verify_jwt on for functions your own app calls with a signed-in user, like create-checkout-session. (One exception: if your project uses the new sb_publishable_ keys and those functions also get 401 Invalid JWT, Supabase's docs say to turn the platform check off there too and verify the user inside the function.)
The part you must not skip: verify Stripe's signature in code
With verify_jwt off, anyone who knows your function URL can POST to it. If your handler trusts the body, someone can send a fake checkout.session.completed and give themselves Pro for free.
Stripe's signature is now your only lock. Three rules on Supabase:
-
Read the raw body with
await req.text(). Never callreq.json()first. Re-serialized JSON no longer matches the signature. -
Use
constructEventAsyncwith the SubtleCrypto provider. Edge Functions run on Deno, where the synchronousconstructEventthrowsSubtleCryptoProvider cannot be used in a synchronous context. -
Use the
whsec_...secret of this exact endpoint. The Stripe CLI'sstripe listensecret, a test-mode endpoint and a live endpoint each have different secrets.
// 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(); // raw text, before any JSON parsing
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") {
// Grant access here, using session.client_reference_id to find the user.
}
break;
}
// customer.subscription.updated / deleted, charge.refunded, ...
}
return Response.json({ received: true });
});
Set the secrets in Supabase, not in code:
supabase secrets set STRIPE_SECRET_KEY=sk_test_... STRIPE_WEBHOOK_SECRET=whsec_...
Two more things worth adding before launch:
-
Stripe can deliver the same event more than once. Save
event.idin a table with a unique constraint and skip ids you've already processed. - Return 500 if your database write fails. Stripe then retries. Returning 200 on failure loses the event.
Test it before real customers do
Locally:
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
stripe listen prints its own whsec_.... Put that one in your local .env.
Then check the security side too. A request with no signature should get a 400, not a 200:
curl -i -X POST https://<project-ref>.supabase.co/functions/v1/stripe-webhook \
-H "Content-Type: application/json" -d '{"type":"checkout.session.completed"}'
If that returns 200, your function accepts forged events. Fix that before you go live.
Quick symptom table
| What you see | Cause | Fix |
|---|---|---|
| 401, empty function logs |
verify_jwt is on for the webhook |
verify_jwt = false / --no-verify-jwt
|
400 No signatures found matching the expected signature
|
Wrong whsec_, or the body was parsed before verifying |
This endpoint's secret, and await req.text()
|
SubtleCryptoProvider cannot be used in a synchronous context |
constructEvent on Deno |
constructEventAsync + createSubtleCryptoProvider()
|
| 200 but the user never gets access | Handler ignores the event type, or can't find the user | Pass client_reference_id when creating the Checkout Session |
I sell a $14.98 Paywall Kit with a complete, tested version of this Edge Function and a go-live checklist. Everything you need for this fix is above.
Top comments (0)