DEV Community

FixLoop
FixLoop

Posted on Originally published at fixloop.dev

Stripe webhook 401 on Supabase Edge Functions: verify_jwt is blocking it

Checkout works. Stripe's dashboard shows the webhook failing with 401. Your Edge Function logs are empty for that timestamp. That combo is almost never your Stripe code. It's the Supabase JWT check running before your handler.

What it looks like

In Stripe Workbench, open the endpoint and look at a failed delivery. The response body comes from the platform, not from your return new Response(...):

{
  "code": 401,
  "message": "Missing authorization header"
}
Enter fullscreen mode Exit fullscreen mode

Sometimes it's Invalid JWT instead. Same thing. Other tells:

  • No invocation in the function logs for that delivery.
  • The function works when you call it yourself with Authorization: Bearer <anon key>.
  • Local stripe listen + supabase functions serve --no-verify-jwt works, production 401s.

Why it happens

Every Edge Function request goes through two layers:

  1. verify_jwt (on by default). The platform wants a Supabase JWT in Authorization. Missing or wrong means 401 and your code never runs.
  2. Your handler. This is where Stripe's signature check belongs.

Stripe sends Stripe-Signature and a JSON body. It has no Supabase token, so layer 1 always rejects it. Supabase's own function config docs use a Stripe webhook as the example for turning this off.

Fix 1: turn off JWT verification for that one function

In supabase/config.toml (use the function's folder name):

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

Redeploy, or supabase functions deploy stripe-webhook --no-verify-jwt. You can also flip the toggle in the dashboard, but if Lovable redeploys the function, a dashboard-only toggle can come back on. Put it in config so it sticks. Leave JWT verification on for every user-facing function.

Fix 2: verify the signature on the raw body

Now anyone can POST to that URL, so the handler has to prove it's Stripe. Two gotchas on Deno:

  • Read the body with req.text() first. Stripe's docs: "Stripe requires the raw body of the request to perform signature verification." req.json() before verifying will break it.
  • Use constructEventAsync, not constructEvent. Deno's crypto is async.
import Stripe from "npm:stripe";

const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY")!);
const cryptoProvider = Stripe.createSubtleCryptoProvider();

Deno.serve(async (req) => {
  const signature = req.headers.get("Stripe-Signature") ?? "";
  const body = await req.text();

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

  // handle event.type, then acknowledge fast
  return new Response(JSON.stringify({ received: true }), {
    headers: { "Content-Type": "application/json" },
    status: 200,
  });
});
Enter fullscreen mode Exit fullscreen mode

Return 400 on a bad signature (not 401, or you'll think the JWT bug is back) and 200 quickly once you've accepted the event.

The secret mix-up

  • Production deliveries are signed with the endpoint secret from Stripe Workbench (starts with whsec_).
  • stripe listen prints a different whsec_. Don't mix them.
  • Test mode and live mode each have their own.
  • Keep STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET in Edge Function secrets, never in VITE_ client env.

Checklist

  • 401 body says Missing authorization header or Invalid JWT? JWT gate. Set verify_jwt = false on that function only.
  • req.text(), then constructEventAsync(...).
  • 400 on bad signature, 200 when accepted.
  • Dashboard whsec_ in prod, CLI secret only with stripe listen.

Full guide

Longer version with source links and an FAQ ("does turning off verify_jwt make it public?", constructEvent vs constructEventAsync):

https://fixloop.dev/guides/stripe-webhook-401-supabase-edge-functions

If the webhook "succeeds" but the row never changes, that's usually RLS silently blocking the update, not Stripe. If it's login that broke after deploy, I wrote up the 3 redirect lists.


I run FixLoop. There's a free scan for leaked keys and missing headers, and we fix broken Lovable/Supabase apps for a fixed price. No fix, no pay.

Top comments (1)

Collapse
 
challan116ux profile image
challan116-ux •

The empty function log is the diagnostic gift here — if your handler never ran, debugging your handler is wasted time, and that one observation should move the search up a layer to the platform in front of it. The local-versus-production split makes it worse: a local setup that disables the check by flag will pass every test while guaranteeing the production failure. Matching the local environment's auth behaviour to production, even when it is inconvenient, is what turns this from a surprise into a test case.