Stripe webhook fails on a Supabase Edge Function: 401 or “No signatures found”

Short answer

Supabase Edge Functions check for a valid Supabase JWT before your code runs (verify_jwt, on by default), and Stripe can't send one, so the platform answers 401 and your handler never sees the event. Fix: Read the failed delivery in Stripe; Turn off JWT verification for the webhook function only; Verify the signature on the raw body, asynchronously; Use the signing secret of this exact endpoint; Point Stripe at the function URL and pick only the events you handle; Answer with a 2xx fast, then update your database; Test end to end, then switch to live.

You'll see:
  • Stripe's Event deliveries tab shows failed deliveries with HTTP 401, and the function's logs show nothing from your code
  • The response body is {"code":401,"message":"Missing authorization header"} or mentions an invalid JWT
  • Or a 400 with No signatures found matching the expected signature for payload
  • Or the error SubtleCryptoProvider cannot be used in a synchronous context
  • Customers pay in Stripe, but the app never marks the order or subscription as paid

Why does this happen?

Supabase Edge Functions check for a valid Supabase JWT before your code runs (verify_jwt, on by default), and Stripe can't send one, so the platform answers 401 and your handler never sees the event. Once requests get through, Stripe's signature check fails if the code parses the body before verifying it, calls the synchronous constructEvent in Deno, or uses the signing secret of a different endpoint (the Stripe CLI, a sandbox or live mode each have their own).

How do you fix it?

  1. Read the failed delivery in Stripe

    In Stripe Workbench open Webhooks, select the endpoint and open Event deliveries. A 401 with Missing authorization header or an invalid-token message comes from Supabase's platform check, before your code (step 2). A 400 carrying Stripe's own error text comes from your function's signature check (steps 3 and 4).

  2. Turn off JWT verification for the webhook function only

    Supabase's docs use exactly this case as the example: Stripe webhooks must be publicly reachable because Stripe has no user token. Set verify_jwt = false for that one function in supabase/config.toml and redeploy, or deploy it with --no-verify-jwt. Keep verification on for functions your users call; the Stripe signature check in the next step becomes this function's authentication.

    # supabase/config.toml
    [functions.stripe-webhook]
    verify_jwt = false
    
    # then deploy (the flag does the same thing)
    npx supabase functions deploy stripe-webhook --no-verify-jwt
  3. Verify the signature on the raw body, asynchronously

    Stripe signs the exact bytes it sent, so read the body with req.text(), not req.json(). Deno has no Node crypto, so create Stripe.createSubtleCryptoProvider() and call constructEventAsync; the synchronous constructEvent throws with the Web Crypto provider. This follows Supabase's official Stripe webhook example (which names the API key STRIPE_API_KEY; Lovable stores it as STRIPE_SECRET_KEY).

    // 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_SECRET_KEY") as string);
    const cryptoProvider = Stripe.createSubtleCryptoProvider(); // Web Crypto in Deno
    
    export default {
      // auth: 'none' because the Stripe signature is the check
      fetch: withSupabase({ auth: "none" }, async (req) => {
        const signature = req.headers.get("Stripe-Signature");
        const body = await req.text(); // raw body, never 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, then answer quickly
        return Response.json({ received: true });
      }),
    };
  4. Use the signing secret of this exact endpoint

    Stripe calls a wrong endpoint secret the most common cause of the signature error. The secret printed by stripe listen is different from a Dashboard endpoint's secret (Workbench → the endpoint → Reveal secret); both start with whsec_. An endpoint you created in a sandbox is not your live endpoint either, so going live means a live endpoint and its own secret. Store it as an Edge Function secret with the name your code reads.

    npx supabase secrets set STRIPE_WEBHOOK_SIGNING_SECRET=whsec_...
    npx supabase secrets list
  5. Point Stripe at the function URL and pick only the events you handle

    Deployed functions live at https://<project-ref>.supabase.co/functions/v1/<function-name>. Create the event destination with that URL and select just the events your code handles, such as checkout.session.completed, payment_intent.succeeded or customer.subscription.updated. Stripe requires a publicly reachable HTTPS URL.

  6. Answer with a 2xx fast, then update your database

    Stripe expects a quick 2xx response before any slow work. Live-mode deliveries that fail are retried for up to three days; sandbox deliveries three times over a few hours. To write the result to tables protected by RLS, use a secret key from the function's default secrets (SUPABASE_SECRET_KEYS, or the legacy SUPABASE_SERVICE_ROLE_KEY): these bypass Row Level Security, so they must never reach the browser.

  7. Test end to end, then switch to live

    Locally, run the function with --no-verify-jwt, forward events with the Stripe CLI and trigger one. After deploying, resend a failed delivery from Event deliveries. When you go live, switch to your live API key, create the live webhook endpoint and secret, and update price IDs: Lovable notes that price IDs differ between test and live mode.

    npx supabase functions serve stripe-webhook --no-verify-jwt
    stripe listen --forward-to localhost:54321/functions/v1/stripe-webhook
    stripe trigger payment_intent.succeeded

Common questions

Is it safe to turn off `verify_jwt` for the webhook?

For this function, yes, provided it verifies Stripe's signature. Supabase lists auth: 'none' as the mode for signed webhooks and uses the Stripe webhook as its verify_jwt = false example. Keep the platform check on for functions your own users call.

Why do I get a 401 when my secrets are set correctly?

A 401 with Missing authorization header or an invalid-JWT message comes from Supabase's platform check, which runs before your code. Your secrets aren't read yet. Turn off verify_jwt for the webhook function and redeploy.

It works with `stripe listen` but fails from the Stripe Dashboard. Why?

They are different endpoints with different signing secrets. Stripe prints the CLI secret in the terminal; the Dashboard endpoint has its own under Reveal secret. Put the Dashboard endpoint's secret in your Edge Function secrets.

Does a Lovable app need Stripe webhooks at all?

Not always. Lovable's docs say its Stripe integration doesn't use webhooks by default: the app checks payment and subscription status with Stripe directly. Add webhooks when you need to react to events as they happen, such as fulfilling orders.

Common with:

Related fixes