DEV Community

Agent Knowledge Research
Agent Knowledge Research

Posted on

Stripe webhook returns 401 on Supabase Edge Functions (Lovable, Bolt): the --no-verify-jwt fix

You connected Stripe to your Lovable, Bolt or hand-written Supabase app. Checkout works and the payment shows up in Stripe. But the user never gets Pro.

In Stripe → Developers → Webhooks → your endpoint → Event deliveries, every attempt failed with HTTP 401 and a response body that says Missing authorization header (or Invalid JWT). And your Edge Function logs are empty.

Empty logs are the clue. Your code never ran.

Why it happens

Supabase Edge Functions run a platform check called verify_jwt before your function. It's on by default. It expects an Authorization: Bearer <Supabase JWT> header, which your own frontend sends automatically when it calls supabase.functions.invoke().

Stripe doesn't have a Supabase login token. It signs each request with your webhook secret and puts that signature in the Stripe-Signature header. So the platform turns Stripe away with a 401, and Stripe keeps retrying (for up to 3 days in live mode).

AI builders hit this a lot because they generate a perfectly good webhook function, deploy it with the default settings, and never look at Stripe's delivery log.

The fix: turn off the JWT check for the webhook function only

Pick one of these.

A. supabase/config.toml (best, because it stays in your repo and survives redeploys)

[functions.stripe-webhook]
verify_jwt = false
Enter fullscreen mode Exit fullscreen mode

Use your function's folder name if it isn't stripe-webhook. Then redeploy:

supabase functions deploy stripe-webhook
Enter fullscreen mode Exit fullscreen mode

B. A flag at deploy time

supabase functions deploy stripe-webhook --no-verify-jwt
Enter fullscreen mode Exit fullscreen mode

The flag only applies to that deploy. If a later deploy runs without it and config.toml doesn't say false, the 401s come back. That's why A is better.

C. Dashboard

Supabase Dashboard → Edge Functions → your webhook function → details → switch JWT verification off.

If Lovable or Bolt deploys your functions for you, ask it to add the config.toml block above, for example:

Add [functions.stripe-webhook] with verify_jwt = false to supabase/config.toml. Don't change any other function's setting. Keep the Stripe signature check in the webhook.

After it redeploys, open the function in the Supabase Dashboard and confirm JWT verification really is off.

Then in Stripe, open one of the failed events and click Resend. You should get a 200.

Leave verify_jwt on for functions your own app calls with a signed-in user, like create-checkout-session. (One exception: if your project uses the new sb_publishable_ keys and those functions also get 401 Invalid JWT, Supabase's docs say to turn the platform check off there too and verify the user inside the function.)

The part you must not skip: verify Stripe's signature in code

With verify_jwt off, anyone who knows your function URL can POST to it. If your handler trusts the body, someone can send a fake checkout.session.completed and give themselves Pro for free.

Stripe's signature is now your only lock. Three rules on Supabase:

  1. Read the raw body with await req.text(). Never call req.json() first. Re-serialized JSON no longer matches the signature.
  2. Use constructEventAsync with the SubtleCrypto provider. Edge Functions run on Deno, where the synchronous constructEvent throws SubtleCryptoProvider cannot be used in a synchronous context.
  3. Use the whsec_... secret of this exact endpoint. The Stripe CLI's stripe listen secret, a test-mode endpoint and a live endpoint each have different secrets.
// supabase/functions/stripe-webhook/index.ts
import Stripe from "npm:stripe@^23";

const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY")!, {
  httpClient: Stripe.createFetchHttpClient(),
});
const cryptoProvider = Stripe.createSubtleCryptoProvider();

Deno.serve(async (req) => {
  if (req.method !== "POST") return new Response("Method not allowed", { status: 405 });

  const signature = req.headers.get("stripe-signature");
  if (!signature) return new Response("Missing signature", { status: 400 });

  const body = await req.text(); // raw text, before any JSON parsing

  let event: Stripe.Event;
  try {
    event = await stripe.webhooks.constructEventAsync(
      body,
      signature,
      Deno.env.get("STRIPE_WEBHOOK_SECRET")!,
      undefined,
      cryptoProvider,
    );
  } catch (err) {
    console.error("Stripe signature check failed:", (err as Error).message);
    return new Response("Invalid signature", { status: 400 });
  }

  switch (event.type) {
    case "checkout.session.completed": {
      const session = event.data.object as Stripe.Checkout.Session;
      if (session.payment_status === "paid") {
        // Grant access here, using session.client_reference_id to find the user.
      }
      break;
    }
    // customer.subscription.updated / deleted, charge.refunded, ...
  }

  return Response.json({ received: true });
});
Enter fullscreen mode Exit fullscreen mode

Set the secrets in Supabase, not in code:

supabase secrets set STRIPE_SECRET_KEY=sk_test_... STRIPE_WEBHOOK_SECRET=whsec_...
Enter fullscreen mode Exit fullscreen mode

Two more things worth adding before launch:

  • Stripe can deliver the same event more than once. Save event.id in a table with a unique constraint and skip ids you've already processed.
  • Return 500 if your database write fails. Stripe then retries. Returning 200 on failure loses the event.

Test it before real customers do

Locally:

supabase functions serve --no-verify-jwt --env-file ./supabase/.env
stripe listen --forward-to http://localhost:54321/functions/v1/stripe-webhook
stripe trigger checkout.session.completed
Enter fullscreen mode Exit fullscreen mode

stripe listen prints its own whsec_.... Put that one in your local .env.

Then check the security side too. A request with no signature should get a 400, not a 200:

curl -i -X POST https://<project-ref>.supabase.co/functions/v1/stripe-webhook \
  -H "Content-Type: application/json" -d '{"type":"checkout.session.completed"}'
Enter fullscreen mode Exit fullscreen mode

If that returns 200, your function accepts forged events. Fix that before you go live.

Quick symptom table

What you see Cause Fix
401, empty function logs verify_jwt is on for the webhook verify_jwt = false / --no-verify-jwt
400 No signatures found matching the expected signature Wrong whsec_, or the body was parsed before verifying This endpoint's secret, and await req.text()
SubtleCryptoProvider cannot be used in a synchronous context constructEvent on Deno constructEventAsync + createSubtleCryptoProvider()
200 but the user never gets access Handler ignores the event type, or can't find the user Pass client_reference_id when creating the Checkout Session

I sell a $14.98 Paywall Kit with a complete, tested version of this Edge Function and a go-live checklist. Everything you need for this fix is above.

Top comments (0)