Why do Stripe's messages to my app get 401 "Authentication Required"?

Checked on

Stripe doesn't sign in to anything, so a 401 means something in front of your webhook code is asking for a login. On Vercel it's usually Deployment Protection on a preview or generated *.vercel.app URL. On Supabase (including apps built with Lovable or Bolt) it's the Edge Function JWT check, which rejects requests without a valid Supabase key. Otherwise it's your own auth middleware on the webhook route. Point the webhook at a URL that needs no login, and let the Stripe signature check keep it secure.

Why this happens

Stripe sends each webhook as a plain POST with a Stripe-Signature header. It can't sign in, can't send a Supabase key and can't add your own headers. So any layer in front of your code that asks for a login answers 401, and your code never runs. Stripe's docs list 401 among the responses you get "when the destination URL has access restrictions".

Three layers usually do this:

WhereWhat answers 401How to tell
VercelDeployment Protection. The recommended "Standard Protection" protects every URL except your production domains. "All Deployments" protects production domains too.The endpoint is a preview, branch or generated project-abc123.vercel.app URL (or any URL, with "All Deployments"). The response body is a Vercel login page, not your JSON.
Supabase Edge FunctionsThe built-in JWT check. Supabase says it "runs before your code. When it fails, your function never executes".The URL is https://<ref>.supabase.co/functions/v1/... and the body is {"code":401,"message":"Missing authorization header"} or an "Invalid Token" or "Invalid JWT" message.
Your own appAuth middleware or a route guard that protects all /api/* routes, such as a Next.js middleware.ts (proxy.ts from Next.js 16).The body is your app's own error, such as {"error":"Unauthorized"}.

How to fix it, step by step

1. Read the 401 body in Stripe. In the Stripe Dashboard, open Workbench → Webhooks, select your endpoint and open Event deliveries. Click a failed delivery and read the response body. It tells you which row of the table above applies. Note the exact endpoint URL too.

You can also reproduce it from a terminal. A 401 to a request with no Stripe headers confirms the block is in front of your code:

curl -i -X POST "https://YOUR-WEBHOOK-URL" -H "Content-Type: application/json" -d '{}'

Once the block is gone, the same request should get a 400 from your own code, because there's no valid signature. That's the result you want.

2a. Vercel: use your production domain, or add the bypass secret.

  • Best fix: set the Stripe endpoint to your production domain, such as https://yourapp.com/api/stripe/webhook. With Standard Protection, production domains need no login.
  • If the project uses "All Deployments", or you need a preview URL to receive webhooks, use Vercel's Protection Bypass for Automation. Create a secret in the project's Settings → Deployment Protection and add it to the endpoint URL as a query parameter. Vercel's docs give this exact Stripe example:
https://your-app.vercel.app/api/stripe-webhook?x-vercel-protection-bypass=YOUR_SECRET
  • Treat that URL as a secret. If you regenerate or delete the bypass secret, redeploy and update the URL in Stripe.
  • Don't switch Deployment Protection off for the whole project just for this.

If Stripe shows a 307 or 308 instead of a 401, the request is being redirected, for example from yourapp.com to www.yourapp.com. See Stripe webhook failing with a 307 or 308 redirect.

2b. Supabase Edge Function: turn off the JWT check for the webhook function only. Stripe can't send a Supabase key, so the webhook function has to skip the built-in check and rely on the Stripe signature instead. Supabase's own Stripe example says: "Stripe verifies the request via its signature, so deploy with verify_jwt = false". Supabase now calls this check legacy and recommends handling auth in your function code. Pick one way:

  • In supabase/config.toml (Lovable and Bolt projects use this file), add a block for the webhook function, then redeploy it:
[functions.stripe-webhook]
verify_jwt = false
  • Or deploy with the CLI flag:
supabase functions deploy stripe-webhook --no-verify-jwt
  • Or in the Supabase Dashboard, open Edge Functions, open the function's details and turn off JWT verification.

Use your own function's name in place of stripe-webhook. Only do this for the webhook. Functions your app calls from the browser should keep checking who the caller is.

2c. Your own middleware: exclude the webhook route. Find the middleware or route guard that returns the 401 and exclude the webhook path. In Next.js that usually means adding the path to your list of public routes, or changing the matcher in middleware.ts (or proxy.ts) so it skips /api/stripe/webhook. The webhook route must not require a session cookie or a signed-in user.

Keep it secure: verify the Stripe signature

Removing the login check is safe only if the handler checks that each request really came from Stripe. Read the raw body and verify the Stripe-Signature header with the endpoint's whsec_ signing secret. Stripe's docs warn that "any manipulation to the raw body of the request causes the verification to fail". In a Supabase Edge Function (Deno), use the async version with the Web Crypto provider, as Supabase's example does:

import Stripe from 'npm:stripe@^22'

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

export default {
  fetch: async (req: Request) => {
    const signature = req.headers.get('Stripe-Signature') ?? ''
    const body = await req.text() // raw body: don't use req.json() here
    let event
    try {
      event = await stripe.webhooks.constructEventAsync(
        body,
        signature,
        Deno.env.get('STRIPE_WEBHOOK_SECRET')!,
        undefined,
        cryptoProvider,
      )
    } catch (err) {
      return new Response(`Webhook signature error: ${(err as Error).message}`, { status: 400 })
    }
    // handle event.type here, then:
    return Response.json({ received: true })
  },
}

We tested this code with stripe-node 22.6.2. A correctly signed payload passed. A re-serialised body and a wrong secret were both rejected with StripeSignatureVerificationError. The test and live endpoints have different whsec_ secrets, so use the one for the endpoint that is failing. If you now see "No signatures found matching the expected signature", see Stripe webhook "No signatures found".

Resend the events that failed

Fixing the endpoint doesn't replay the events that already failed. In live mode, Stripe retries a failed delivery for up to three days, with longer gaps each time. In a sandbox it retries three times over a few hours. You can also resend events yourself:

  • In the Stripe Dashboard, open the event and click Resend. This works for up to 15 days after the event was created.
  • With the Stripe CLI, run stripe events resend <event_id> --webhook-endpoint=<endpoint_id>. This works for up to 30 days.

Resend the payments that failed, then check that your database caught up. Your handler may now get the same event twice (your resend and Stripe's retry), so skip event IDs you have already processed.

Fix prompt for your AI builder

Paste this into Lovable, Bolt, Cursor, Claude, ChatGPT, Replit, v0 or whichever tool you build with. Replace the bracketed parts first.

Fix prompt
My Stripe webhook endpoint returns 401 for every delivery. The endpoint URL is [PASTE URL], and the 401 response body Stripe shows is [PASTE BODY].
Please fix it without changing unrelated code:
1. If this is a Supabase Edge Function, set verify_jwt = false for the webhook function ONLY in supabase/config.toml ([functions.FUNCTION_NAME]) and tell me how to redeploy it. Leave the JWT check on for every other function.
2. If this is a Next.js or Vercel route, make sure the webhook path is excluded from any auth middleware (middleware.ts or proxy.ts) or route protection, and tell me whether I should point Stripe at my production domain instead of a preview URL.
3. In the webhook handler, read the raw request body (req.text(), not req.json()) and verify the Stripe-Signature header with the webhook signing secret from an environment variable. In Deno or Edge runtimes use await stripe.webhooks.constructEventAsync(..., Stripe.createSubtleCryptoProvider()). Return 400 if verification fails, and 200 only after the event is handled.
4. List every environment variable or secret the handler needs and where I must set it.
Don't disable RLS or remove auth from any other route.

How to confirm it's fixed

  1. Run the curl command from step 1 again. You should get a 400 from your own code (no valid signature), not a 401.
  2. In a Stripe sandbox, run stripe trigger checkout.session.completed with the Stripe CLI and check that the sandbox endpoint's Event deliveries show 200.
  3. For the live endpoint, resend one real failed event. Check that it shows 200 and that the customer's plan or order updated.

How to find out sooner next time

Webhook 401s often start after a redeploy, a move to a new Vercel project, a change to Deployment Protection, or an AI edit that rewrites config.toml or the middleware. Payments still go through, so nothing looks wrong on screen.

Heedza never pays, so it can't see webhook deliveries. Stripe's Event deliveries tab and Stripe's emails about failing webhooks are where these show up. What Heedza does check on a schedule is that your landing, signup, login and checkout pages load and show their forms and pay buttons. It sends you a screenshot and a fix prompt when one of them breaks.

Let Heedza check this for you. Heedza’s payment page check opens your live payment page on a schedule and tells you if the Pay button disappears. It never pays. It never logs in or fills in forms, so it can’t see errors that only show after that.

Made with Lovable, Bolt, Replit, Cursor or v0? Free for 1 site: start free or see prices.

More questions

Is it safe to set verify_jwt = false?

For a Stripe webhook, yes, as long as the handler verifies the Stripe signature. Requests without a valid signature get a 400 and change nothing. Supabase's own Stripe webhook example is set up this way.

Why did it work with stripe listen but not after deploy?

The Stripe CLI forwards events straight to your local server, so nothing stands in front of your code. The deployed URL has Vercel's protection, Supabase's JWT check or your middleware in front of it.

Stripe shows 400, not 401. Is that the same problem?

No. A 400 usually means your code ran and the signature check failed: the body was parsed before verification, or the whsec_ secret belongs to a different endpoint or mode. See Stripe webhook "No signatures found". If deliveries return 200 but nothing updates, see paid but subscription not updated.

Can the 401 come from Stripe itself?

Not on webhook deliveries. The 401 is the response from your URL. But if your own handler calls Stripe's API with a missing or wrong secret key, Stripe answers that call with a 401, and some handlers pass it on. Search your handler for where it returns 401.

Does Heedza check Stripe webhooks?

No. Heedza never pays, so no webhook is sent for its checks. It checks that your checkout page loads and shows its payment form or pay button, and stops there.

Where this comes from