Checkout works. The customer pays. Stripe's delivery log for your webhook shows 401 on every attempt, and your Edge Function logs show nothing at all.
That combination is almost always Supabase's built-in JWT check, not your Stripe code. This post covers why it happens, how to turn it off for the webhook function only, and the step you must take right after, because once the function is public, the Stripe signature is the only thing protecting it. Every claim links to the doc it comes from (see Sources at the end).
What the 401 looks like
Open the endpoint in the Stripe Dashboard and look at a failed delivery. If the response body is one of these, the request never reached your handler:
{ "code": 401, "message": "Missing authorization header" }
{ "code": 401, "message": "Invalid Token or Protected Header formatting" }
Supabase's troubleshooting guide says both messages "come from the legacy auth verification check", which "runs before your code. When it fails, your function never executes." If the body is a message you wrote yourself (or empty), your own code returned the 401 and you should look there instead.
Stripe's side agrees: its webhook docs list 4xx responses, including 401 and 403 "when the destination URL has access restrictions", as failed deliveries, and ask that your endpoint be "publicly accessible to the internet".
Why it happens
Supabase's function configuration docs: "By default, Edge Functions require a valid JWT in the authorization header." Stripe sends a Stripe-Signature header and a JSON body. It has no Supabase user token, so the platform rejects it with a 401 before Deno.serve (or your exported fetch) ever runs.
Supabase calls this out directly: "Stripe webhooks need to be publicly accessible (Stripe doesn't have your user tokens)."
Fix 1: turn off JWT verification for the webhook function only
In config.toml (sticks across deploys)
This is the exact example in Supabase's function configuration docs:
# supabase/config.toml
# Disables authentication for the Stripe webhook.
[functions.stripe-webhook]
verify_jwt = false
The key after functions. is your function's name. Leave every other function on the default.
On the CLI
The supabase functions deploy reference lists --no-verify-jwt ("Disable JWT verification for the Function"), and Supabase's own Stripe example deploys with it:
supabase functions deploy stripe-webhook --no-verify-jwt
For local testing, the same flag works on serve, and the example pairs it with stripe listen:
# terminal 1
supabase functions serve --no-verify-jwt --env-file ./supabase/.env.local
# terminal 2
stripe listen --forward-to localhost:54321/functions/v1/stripe-webhook
In the Dashboard
Supabase's 401 troubleshooting guide also lists a Dashboard route: open the function's detail tab and toggle off JWT verification. If your function gets redeployed by a tool (an AI app builder, a CI job), put the setting in config.toml as well so a redeploy doesn't quietly turn it back on. Users have reported exactly that in a Supabase CLI issue (linked below), where "Enforce JWT verification" was enabled again after a push.
Fix 2: verify the Stripe signature, the Deno way
Supabase's docs carry a warning with the flag: it "will allow anyone to invoke your Edge Function without a valid JWT." Your webhook URL is now public. If the handler trusts the JSON body, anyone who knows the URL can POST a hand-written checkout.session.completed and unlock your product.
Supabase's "Securing Edge Functions" page says the same for external webhooks: providers like Stripe "sign the request body with their own shared secret", so skip the credential check, "then verify the provider's signature inside the handler. Keep verify_jwt = false."
Two details matter in Deno:
-
Use the raw body. Stripe: "Stripe requires the raw body of the request to perform signature verification... Any manipulation to the raw body of the request causes the verification to fail." Read it with
await req.text(). Don't callreq.json()first. -
Use the async verifier with a Web Crypto provider. stripe-node's
SubtleCryptoProvider"only supports asynchronous operations". Its synchronous path throwsSubtleCryptoProvider cannot be used in a synchronous context.So useconstructEventAsyncwithStripe.createSubtleCryptoProvider(), notconstructEvent.
This is the shape of Supabase's official Stripe webhook example:
// supabase/functions/stripe-webhook/index.ts
import Stripe from 'npm:stripe@^22'
import { withSupabase } from 'npm:@supabase/server@^1'
const stripe = new Stripe(Deno.env.get('STRIPE_API_KEY') as string)
// Needed to use the Web Crypto API in Deno
const cryptoProvider = Stripe.createSubtleCryptoProvider()
// Stripe verifies the request via its signature, so deploy with verify_jwt = false.
export default {
fetch: withSupabase({ auth: 'none' }, async (req) => {
const signature = req.headers.get('Stripe-Signature')
const body = await req.text() // raw body, not req.json()
let event
try {
event = await stripe.webhooks.constructEventAsync(
body,
signature!,
Deno.env.get('STRIPE_WEBHOOK_SIGNING_SECRET')!,
undefined,
cryptoProvider,
)
} catch (err) {
return new Response((err as Error).message, { status: 400 })
}
// handle event.type here (e.g. checkout.session.completed)
return Response.json({ ok: true })
}),
}
If you're on a plain Deno.serve(async (req) => { ... }) handler instead of the withSupabase wrapper, the three lines that matter are the same: await req.text(), constructEventAsync(...) with cryptoProvider, and a 400 when it throws.
Fix 3: the right signing secret for the right endpoint
A 401 that turns into a 400 after Fix 1 usually means your code is now running but the signature doesn't match. Check which whsec_ value the function has.
From Stripe's webhook docs: "Stripe generates a unique secret key for each endpoint. If you use the same endpoint for both test and live API keys, the secret is different for each one. Additionally, if you use multiple endpoints, you must obtain a secret for each one." The stripe listen command prints its own signing secret too ("Your webhook signing secret is ..."), which is for local forwarding, not your deployed endpoint.
Set the secret for the deployed function with the Supabase CLI (or under Edge Function Secrets in the Dashboard):
supabase secrets set STRIPE_WEBHOOK_SIGNING_SECRET=whsec_...
Supabase's secrets docs note that functions "read a new secret immediately, so you don't need to redeploy." When you go live, swap in the live endpoint's secret. The test one won't verify live events.
How to check
Two curl calls tell you most of what you need. Replace the URL with your function's (https://<project-ref>.supabase.co/functions/v1/<name>).
# 1) Unsigned POST, no Authorization header (this is what Stripe "looks like" to the platform)
curl -i -X POST -H 'content-type: application/json' -d '{}' \
https://<project-ref>.supabase.co/functions/v1/stripe-webhook
-
401with"Missing authorization header": JWT verification is still on. Go back to Fix 1. -
400(with the official example, stripe-node's message isNo stripe-signature header value was provided.): good. The function is reachable and rejects unsigned events. -
200: the function is reachable but accepts unsigned events. Go to Fix 2 now.
Then, in the Stripe Dashboard, resend a failed event and confirm the delivery shows a 2xx. Stripe's docs say Dashboard resends work "for up to 15 days after the event creation", and in live mode Stripe itself keeps retrying for up to three days.
If you'd rather not run the curls by hand, WebhookProof's free checker runs the reachability and unsigned-event checks against your URL with 3 harmless requests: Stripe webhooks on Supabase Edge Functions: 401s and signature errors.
Checklist
- [ ]
verify_jwt = falsefor the webhook function only, insupabase/config.toml(and/or deployed with--no-verify-jwt) - [ ] Handler reads
await req.text()before anything else touches the body - [ ] Verification uses
constructEventAsync+Stripe.createSubtleCryptoProvider()and returns 400 on failure - [ ]
STRIPE_WEBHOOK_SIGNING_SECRETis thewhsec_for this endpoint in this mode (not thestripe listenone in production) - [ ] An unsigned
curlgets 400, and a resent Dashboard event gets 2xx
WebhookProof is an independent tool, not affiliated with Stripe, Inc. or Supabase, Inc. Quotes are from the linked documentation as read on 2026-10-18.
Sources
- Supabase, Function Configuration (default JWT requirement,
verify_jwt = falseexample forstripe-webhook,--no-verify-jwtcaution): https://supabase.com/docs/guides/functions/function-configuration - Supabase, Edge Function 401 error response (error bodies, "runs before your code", Dashboard / CLI / Management API fixes): https://supabase.com/docs/guides/troubleshooting/edge-function-401-error-response
- Supabase, Securing Edge Functions (external webhooks:
auth: 'none', verify signature, keepverify_jwt = false): https://supabase.com/docs/guides/functions/auth - Supabase, Handling Stripe Webhooks example: https://supabase.com/docs/guides/functions/examples/stripe-webhooks
- Supabase example source (
constructEventAsync,createSubtleCryptoProvider,req.text()): https://github.com/supabase/supabase/blob/master/examples/edge-functions/supabase/functions/stripe-webhooks/index.ts - Supabase example README (
serve --no-verify-jwt,deploy --no-verify-jwt,stripe listen): https://github.com/supabase/supabase/blob/master/examples/edge-functions/supabase/functions/stripe-webhooks/README.md - Supabase CLI reference,
supabase functions deploy(--no-verify-jwtflag): https://supabase.com/docs/reference/cli/supabase-functions-deploy - Supabase, Environment variables / secrets (
supabase secrets set, no redeploy needed): https://supabase.com/docs/guides/functions/secrets - Supabase CLI issue #3397 (users reporting
verify_jwt = falsenot applied / JWT enforcement re-enabled after a push): https://github.com/supabase/cli/issues/3397 - Stripe, Receive Stripe events in your webhook endpoint (raw body, per-endpoint and per-mode secrets, 4xx delivery errors, retries, resend window): https://docs.stripe.com/webhooks
- stripe-node,
SubtleCryptoProvidersource (async-only): https://github.com/stripe/stripe-node/blob/master/src/crypto/SubtleCryptoProvider.ts - stripe-node,
Webhooks.tssource (error messages): https://github.com/stripe/stripe-node/blob/master/src/Webhooks.ts
Top comments (0)