This article was created with the help of AI. Each step was checked against the Supabase and Stripe docs listed at the end.
Short answer: Supabase checks every call to an Edge Function for a login token (a JWT). Stripe does not send one. So Supabase turns Stripe away with a 401 before your code runs. Turn that check off for the webhook function only, then check Stripe's signature in your code.
Signs you have this problem
- In Stripe, the webhook's Event deliveries tab shows failed deliveries with status 401.
- The reply body says something like
Missing authorization headerorInvalid JWT. - Your function logs show nothing, because your code never ran.
If the 401 body is a message you wrote, the 401 comes from your own code, not from Supabase. Look at your handler instead.
Step 1: Turn off the JWT check for this one function
Pick one way.
A. In supabase/config.toml (best, because it lives in your code):
[functions.stripe-webhook]
verify_jwt = false
Then deploy again:
supabase functions deploy stripe-webhook
B. With a flag when you deploy:
supabase functions deploy stripe-webhook --no-verify-jwt
C. In the Supabase Dashboard: open the function and switch off JWT verification.
Keep config.toml and the Dashboard setting the same.
When you test on your laptop, use supabase functions serve --no-verify-jwt.
Important: with the check off, anyone who knows the address can call this function. The Stripe signature check in Step 2 is now your only lock. Do not skip it. Keep the JWT check on for all your other functions.
Step 2: Check the Stripe signature in your code
Three rules:
- Read the body as raw text with
await req.text(). Do not callreq.json()first, or the signature will never match. - On Supabase (which runs Deno), use
constructEventAsyncwith Stripe's crypto provider. The plainconstructEventdoes not work here. - Use the signing secret (
whsec_...) of this exact endpoint.
// supabase/functions/stripe-webhook/index.ts
import Stripe from 'npm:stripe@^22' // version taken from Supabase's own example
const stripe = new Stripe(Deno.env.get('STRIPE_API_KEY')!)
const cryptoProvider = Stripe.createSubtleCryptoProvider()
Deno.serve(async (req) => {
const signature = req.headers.get('Stripe-Signature')
if (!signature) return new Response('Missing signature', { status: 400 })
const body = await req.text() // raw text, not JSON
let event: Stripe.Event
try {
event = await stripe.webhooks.constructEventAsync(
body,
signature,
Deno.env.get('STRIPE_WEBHOOK_SIGNING_SECRET')!,
undefined,
cryptoProvider,
)
} catch (err) {
console.error('Bad signature:', (err as Error).message)
return new Response('Invalid signature', { status: 400 })
}
// Handle the events you need here, e.g. checkout.session.completed
console.log(`Got ${event.type} (${event.id})`)
return Response.json({ received: true })
})
This matches Supabase's own example (examples/edge-functions/supabase/functions/stripe-webhooks/index.ts in the supabase/supabase repo). Their example wraps the handler in withSupabase({ auth: 'none' }) from npm:@supabase/server. Plain Deno.serve does the same job.
Save your keys as secrets, not in code:
supabase secrets set STRIPE_API_KEY=sk_live_... STRIPE_WEBHOOK_SIGNING_SECRET=whsec_...
What often goes wrong next
| You see | What it means | Fix |
|---|---|---|
| 400 "No signatures found matching the expected signature" | Wrong secret, or the body changed | Use the whsec_ of this endpoint. Test and live have different secrets. The stripe listen secret is different from the Dashboard one. |
| 500 "SubtleCryptoProvider cannot be used in a synchronous context" | You used constructEvent
|
Use constructEventAsync with Stripe.createSubtleCryptoProvider()
|
| 200 but the user is not upgraded | The event arrived, but the database write failed or found no user | Read your function logs for the database error. Check how you match the Stripe customer or session to a user in your app. |
Test it
On your laptop:
supabase functions serve stripe-webhook --no-verify-jwt --env-file ./supabase/.env
stripe listen --forward-to http://localhost:54321/functions/v1/stripe-webhook
stripe trigger checkout.session.completed
Put the whsec_ that stripe listen prints into your local .env file.
After you deploy, check that the JWT wall is gone. Send a POST with no signature to your own function:
curl -i -X POST https://YOUR-PROJECT-REF.supabase.co/functions/v1/stripe-webhook
- A 401 that mentions a JWT or a missing authorization header means Supabase is still blocking outside callers. Go back to Step 1.
- A 400
Missing signaturecomes from your own code. That is good: Stripe can now reach it.
Then send a test event from the Stripe Dashboard and look at Event deliveries. You want a 2xx.
Sources
- Supabase, Securing Edge Functions: https://supabase.com/docs/guides/functions/auth
- Supabase, Function configuration (
verify_jwt): https://supabase.com/docs/guides/functions/function-configuration - Supabase, Edge Function 401 error: https://supabase.com/kb/troubleshooting/edge-function-401-error-response
- Supabase example code: https://github.com/supabase/supabase/blob/master/examples/edge-functions/supabase/functions/stripe-webhooks/index.ts
- Stripe, Webhooks: https://docs.stripe.com/webhooks
- Stripe, Verify webhook signatures: https://docs.stripe.com/webhooks#verify-events
More webhook fix guides from Tillping: https://tillping.online/fix?ref=dev
Run a free check, or join the early-bird list. Only scan an address you own or are allowed to test. The free check sends safe test requests (a POST with no signature, a POST with a fake signature, and a GET), never real payment events. If your address redirects, it follows the redirect.
Top comments (0)