Stripe webhook to a Supabase Edge Function returns 401
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 | See our guide on payments that do not unlock the account |
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, send a test event from the Stripe Dashboard and look at Event deliveries. You want a 2xx.
Check it with a free scan
Paste your function address into the free Tillping scan: https://tillping.online/#scan. No account is needed. Only scan an address you own or are allowed to test.
It sends three safe test requests (not real payment events): a POST with no signature, a POST with a fake signature, and a GET. If the reply is a 401 that talks about a JWT or a missing authorization header, it tells you Supabase is still blocking outside callers. Once that wall is gone, it also warns you if your function says OK to the fake signature. Pick Stripe in the provider menu so the test uses Stripe's header.
It never asks for your secret, so it can't prove that real signed events are accepted. Send a test event from Stripe for that.
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/docs/guides/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, Signature errors: https://docs.stripe.com/webhooks/signature
Check your webhook now
Paste your webhook address to see what it answers. Free, no account, no keys.
Check my webhookGet an email when Deep Scans open
One email to confirm, then one when we launch. Leave any time.
