BuiltSafely

Home/Fixes

Supabase

401 "Missing authorization header" on my Stripe webhook (Supabase Edge Function)

Supabase Edge Functions check for a Supabase JWT by default, and Stripe doesn't send one, so the webhook gets 401 before your code runs. Turn verify_jwt off for that one function, then verify Stripe's signature yourself with constructEventAsync.

  • Verified: tested locally
  • Reviewed Oct 2026
  • 9 official sources
Sources Last checked October 26, 2026 · Badge: verified (tested on a throwaway local Supabase stack with the Supabase CLI and Edge Runtime, using a fake webhook secret and self-signed test events; no Stripe account or Stripe API calls) · Sources: official Supabase and Stripe docs (linked below) · Educational, not an audit

You point a Stripe webhook at your Supabase Edge Function, and every delivery fails with a 401. The response body says Missing authorization header, and your function's logs show nothing, because your code never ran. Supabase's troubleshooting guide lists that exact body, {"code":401,"message":"Missing authorization header"}, and says it comes from the platform's built-in check that "runs before your code. When it fails, your function never executes" (Supabase: Edge Function 401 error response). A webhook can't pass that check, so you turn it off for that one function and verify Stripe's signature yourself instead.

The fix

1. Turn off JWT verification for the webhook function only (Verified locally). In supabase/config.toml, use your function's folder name:

[functions.stripe-webhook]
verify_jwt = false

Supabase's function configuration docs use this exact block: "If you want to use Edge Functions without Authorization checks (commonly used for Stripe webhooks), you can configure this in your config.toml" (Supabase: Function configuration). Leave every other function on the default. In our test, the same function and the same signed test event got 401 Missing authorization header with verify_jwt = true, and 200 after we set it to false and restarted the local stack (Verified).

2. Redeploy so the hosted function picks it up (Docs-only: we didn't deploy to a hosted project). Deploy with the CLI after the config.toml change, or pass the flag:

supabase functions deploy stripe-webhook --no-verify-jwt

The CLI reference describes --no-verify-jwt as "Disable JWT verification for the Function" (Supabase CLI: functions deploy), and the config reference notes that "the --no-verify-jwt flag overrides this configuration" (Supabase: CLI config). Supabase's troubleshooting guide also lists a Dashboard route: open the function's details and toggle off JWT verification (Supabase: Edge Function 401 error response).

3. Verify the Stripe signature in the function (Verified locally). This step is not optional. With the platform check off, your signature check is the only thing between the internet and your handler. This is Supabase's Stripe webhook example, which we ran as written:

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)
// Deno needs the Web Crypto provider for signature checks
const cryptoProvider = Stripe.createSubtleCryptoProvider()

export default {
  fetch: withSupabase({ auth: 'none' }, async (req) => {
    const signature = req.headers.get('Stripe-Signature')
    const body = await req.text()   // the 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.message, { status: 400 })
    }
    // only now act on event.type
    return Response.json({ ok: true })
  }),
}

Source: Supabase: Handling Stripe Webhooks. Supabase's auth guide gives the same pattern for any external webhook: "Use auth: 'none' to skip the wrapper's credential check, then verify the provider's signature inside the handler. Keep verify_jwt = false" (Supabase: Securing Edge Functions). Stripe's side: verify "using the JSON payload, the Stripe-Signature header, and the whsec_ webhook signing secret", and "Stripe requires the raw body of the request to perform signature verification" (Stripe: Receive events in your webhook endpoint).

Use constructEventAsync, not constructEvent. Supabase's guide warns that "Deno has no synchronous Node crypto", and that the synchronous call "throws 'SubtleCryptoProvider cannot be used in a synchronous context' on this runtime" (Supabase: Securing Edge Functions). We got that exact error from the synchronous call with the Web Crypto provider (Verified).

In our test, with verify_jwt = false and this code, a correctly signed event got 200, and a missing header, a changed body and a signature made with a different secret each got 400, logged as a failed signature check (Verified). Offline with the Stripe library, re-formatting the JSON before checking it failed, and so did a correctly signed event whose timestamp was 10 minutes old, with "Timestamp outside the tolerance zone" (Verified). Stripe: "Our libraries have a default tolerance of 5 minutes", and "Don't use a tolerance value of 0", which "disables the recency check entirely" (Stripe: Receive events in your webhook endpoint).

4. Keep the signing secret in function secrets, not in code (Verified locally with supabase/functions/.env; production steps Docs-only):

supabase secrets set STRIPE_WEBHOOK_SIGNING_SECRET=whsec_...
supabase secrets set STRIPE_API_KEY=sk_...

Locally, "Edge Functions read secrets from supabase/functions/.env", and that file belongs in .gitignore. For production, supabase secrets set sets secrets "without a .env file" (Supabase: Environment variables). Use the whsec_ value for this exact endpoint. Stripe: "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" (Stripe: Receive events in your webhook endpoint). A wrong secret gives a 400 "No signatures found" error, which has its own page: No signatures found matching the expected signature for payload.

Tested October 26, 2026 on a throwaway local Supabase stack (Supabase CLI 2.122.0, Edge Runtime 1.77.8), running Supabase's Stripe webhook example with npm:stripe@^22. The webhook secret was a made-up whsec_test_ value, and we signed test events ourselves with the Stripe Node library's generateTestHeaderString (stripe-node 23.0.1). No Stripe account, no Stripe API calls, $0. With verify_jwt = true: signed event, no Authorization header, got 401 {"code":"UNAUTHORIZED_NO_AUTH_HEADER","message":"Missing authorization header",…} with an sb-error-code: UNAUTHORIZED_NO_AUTH_HEADER header, and the handler never ran. With verify_jwt = false: signed event 200; no signature, a changed body, or the wrong secret 400. A second function with verify_jwt = false and no signature check returned 200 to a forged event we typed by hand.

Why it happens

  • Edge Functions check for a Supabase JWT by default. "By default, Edge Functions require a valid JWT in the authorization header" (Supabase: Function configuration). "When verify_jwt is enabled (the default), the platform inspects the Authorization header of every request before your function runs… If the header is missing, malformed, or signed with a different key, the platform returns a 401 error, and your code never executes" (Supabase: Authorization headers).
  • Stripe doesn't have a Supabase JWT to send. Supabase: "Stripe webhooks need to be publicly accessible (Stripe doesn't have your user tokens)" (Supabase: Function configuration), and "External providers such as Stripe or GitHub don't send Supabase credentials. They sign the request body with their own shared secret" (Supabase: Securing Edge Functions). Stripe signs every event in the Stripe-Signature header instead (Stripe: Receive events in your webhook endpoint).
  • "Invalid JWT" is the same check. Supabase's guide says the platform check returns either "Invalid JWT" or "Missing authorization header" (Supabase: Edge Function 401 error response). A Stripe delivery sends no Authorization header, so expect the second. If you see "Invalid JWT", something is sending a bad token. The fix for a webhook is still the same.
  • Stripe keeps retrying. Stripe retries failed deliveries "for up to three days with an exponential back off in live mode" (Stripe: Receive events in your webhook endpoint), so the 401s keep coming until the function changes.

What not to do

  • Don't turn off verify_jwt without the signature check. Supabase: "Be careful when using this flag, as it will allow anyone to invoke your Edge Function without a valid JWT" (Supabase: Function configuration). In our test, a function with no signature check returned 200 to a forged checkout.session.completed event typed by hand (Verified).
  • Don't turn it off for the whole project or for functions your app calls. Keep it on for anything called with a user session. See CORS errors calling an Edge Function for browser-called functions.
  • Don't look for a way to make Stripe send a Supabase key or token. Supabase's documented pattern for external webhooks is verify_jwt = false plus the provider's signature check. And never put the service-role or secret key in client code or VITE_ / NEXT_PUBLIC_ variables. It bypasses RLS and belongs only in server-side secrets (check what your build ships).
  • Don't parse the body before checking it. Read it with req.text(). In our test, re-serialized JSON failed the check (Verified).
  • Don't paste the whsec_ secret or your Stripe secret key into the function's code or the repo. Use function secrets.
  • Don't swallow a failed check and return 200. Return 400 and log the reason, so a misconfigured secret shows up in your logs.

AI prompt

My Stripe webhook to a Supabase Edge Function returns 401 "Missing authorization header".
1. In supabase/config.toml, add verify_jwt = false under [functions.<my webhook function name>]
   for that function ONLY. Don't change any other function.
2. Make sure the function verifies the Stripe signature before doing anything:
   read the raw body with req.text(), get the Stripe-Signature header, and call
   await stripe.webhooks.constructEventAsync(body, signature, secret, undefined,
   Stripe.createSubtleCryptoProvider()). Return 400 if it throws.
3. Read the whsec_ signing secret and the Stripe key from Deno.env.get(), never
   hard-code them. Tell me the supabase secrets set commands to run.
4. Do not use the service_role or secret key in client code, and do not add any
   Supabase key to the webhook URL. Show me the diff first and wait for my OK.

How to verify (two accounts)

A webhook has no user accounts, so here the two "accounts" are the real sender and a stranger:

  1. As Stripe: in a sandbox, trigger an event your endpoint subscribes to, for example stripe trigger payment_intent.succeeded with the Stripe CLI (Stripe: Test your handler). The delivery should get a 2xx, and your function logs should show the event. (Docs-only: we signed test events ourselves instead.)
  2. As a stranger: from a terminal, post a hand-written event with no signature: curl -i -X POST https://<project-ref>.supabase.co/functions/v1/stripe-webhook -H 'content-type: application/json' -d '{"type":"checkout.session.completed"}'. Expect 400 and a signature error in the logs, and no change in your database. A 200 here means the signature check is missing.
  3. Call one of your other functions without a session. It should still return 401, which shows verify_jwt is off only for the webhook.

No signatures found matching the expected signature (Stripe) · CORS error calling a Supabase Edge Function · Secret keys in frontend env vars · Rotating secrets after a host exposure · Is the publishable key safe? · Our Supabase RLS guide

Your turn

Did the 401 clear, and did a stranger's unsigned request get a 400? Tell us what your webhook does after the check (orders, credits, subscriptions). We'll add the patterns we see most.

This guide is educational and based on the vendor's public docs as of the review date. Platforms change: check the linked sources. Not a security audit. Disclaimer.

The BuiltSafely weekly

One email a week. What changed, and the one fix that matters.

Platform changes that affect the security of AI-built apps, checked against official docs. Free. Unsubscribe any time.