CheckoutAlarmWatch my app free

Supabase Edge Function returns 401 to Stripe webhooks (and your logs are empty)

Stripe shows 401 for every delivery to your Edge Function, and your function logs show nothing at all. That empty log is the clue: your code never ran. Here is why, how to confirm it, and the fix.

Free tool: decode a 401 or any other webhook error

What it would catch here

A 401 is a failed delivery, so Stripe keeps the event as not delivered. With a read-only key, CheckoutAlarm emails you when events are still undelivered about 15 minutes after they were created.

What it won't catch

It doesn't see the status code, your function logs or which layer sent the 401. It doesn't send test requests to your function. If your function answers 200 but then does nothing, Stripe sees a success and so do we.

Plans

Free: 1 site, checked once a day. Pro: $19 a month, up to 3 sites checked hourly. You prove you own the site first. For Stripe we only take a restricted key with read access to Events, Webhook Endpoints and Charges. Full secret keys are refused. Compare plans

A platform check runs before your code

Every request to an Edge Function passes a platform check, verify_jwt, before your code runs. It is on by default and expects a valid user JWT in the Authorization header. If the header is missing or wrong, the platform answers 401 and your code never executes. Supabase: authorization headers

Stripe doesn't have your users' tokens, so every delivery is turned away. Supabase: function configuration

Why your logs are empty

The Logs view shows platform events, uncaught exceptions and your own console.log lines. Supabase: logging If your code never started, there is nothing of yours to log. The rejected request still lands in the function's edge logs, which Supabase's 401 guide queries in part 03. Supabase: Edge Function 401 error response

How it looks in Stripe

In Workbench, open Webhooks, pick your endpoint and open Event deliveries. Stripe lists 401 as a sign that the address has access limits. Stripe: fix HTTP status codes

Check Invocations, not just Logs

In the Dashboard, open Functions, pick the function, and choose Invocations. It shows each request and response, with headers, status code and duration, and you can filter by status code. Supabase: logging

Read the response body

Supabase's 401 guide says Missing authorization header or Invalid Token or Protected Header formatting come from the built-in check. A message you wrote, or an empty body, means your own code returned the 401. Supabase: Edge Function 401 error response To see the body yourself, send a plain POST to the function URL with no Authorization header, the same way Stripe does.

Or ask the logs table

The same guide has a SQL query for the SQL Editor that sorts recent 401s by cause. It reads function_edge_logs: a row with an empty execution_id means your code never ran. Supabase: Edge Function 401 error response

Turn off the JWT check for this one function

In supabase/config.toml, add a block with your function's name:

[functions.stripe-webhook]
verify_jwt = false

Or pass the flag when you deploy: supabase functions deploy stripe-webhook --no-verify-jwt Supabase CLI: functions deploy Supabase's docs present config.toml as the place to set this once per function. Supabase's 401 guide now recommends turning the built-in check off and handling auth in your function code. Supabase: Edge Function 401 error response

Only do this for the webhook function. Functions called by signed-in users should keep the check.

Keep Stripe's signature check

With the check off, anyone can call the function. Supabase warns about this. Supabase: skipping authorization checks For external webhooks, Supabase says to skip its credential check (auth: 'none' with its withSupabase wrapper) and verify the provider's signature in the handler. Supabase: securing Edge Functions Read the raw body as text and verify it with the async method, as in Supabase's Stripe example: Supabase: handling Stripe webhooks

const body = await req.text() // raw body, not req.json()
const event = await stripe.webhooks.constructEventAsync(
  body, req.headers.get("Stripe-Signature")!, signingSecret,
  undefined, Stripe.createSubtleCryptoProvider())

If the check fails, log why and answer 400, so a rejected request leaves a trace.

Deploy and test again

Deploy the function again. Then resend a failed event from Event deliveries, or wait for the next one. A good delivery shows 200.

If you now see a signature error, see signature verification failed. The raw body rule is the same.

Publishable and secret keys are not JWTs

Supabase is deprecating the anon and service_role keys by the end of 2026, in favor of publishable (sb_publishable_...) and secret (sb_secret_...) keys. Supabase: API keys The new keys are not JWTs. Send them in the apikey header, not as Authorization: Bearer. Supabase: authorization headers Stripe sends neither, so a Stripe webhook still needs verify_jwt = false.

Writing to your database from the webhook

Edge Functions get SUPABASE_SECRET_KEYS, a JSON dictionary of your secret keys, alongside the legacy SUPABASE_SERVICE_ROLE_KEY. Secret keys bypass Row Level Security, so use them only on the server. Supabase: environment variables

If the database write fails, answer non-2xx so Stripe keeps retrying, and log the error. A 200 tells Stripe the event was delivered.

Vercel Deployment Protection

Vercel's Standard Protection protects every deployment except your production domains. A webhook pointed at a preview or a generated *.vercel.app deployment URL hits a login wall and gets a 401. Point the live endpoint at your real domain. If you picked "All Deployments", your production domain is protected too. Vercel: Deployment Protection

Testing a preview? Vercel's Protection Bypass for Automation can go in the webhook URL as ?x-vercel-protection-bypass=… for services that can't set headers. Treat that whole URL as a password. Vercel: Protection Bypass for Automation

A login check in proxy or middleware

In Next.js 16, middleware is renamed proxy. Without a matcher, it runs on every request. If it answers 401 to anyone without a session, Stripe gets a 401 too. Leave the webhook path out with a negative matcher, as in the Next.js docs. Ask your AI builder: "Exclude /api/webhooks/stripe from the auth proxy matcher." Next.js: proxy matcher

Keep Stripe's own check

Skipping a login check is safe only if the route checks the Stripe-Signature header with your whsec_ secret. Stripe: verify events

Questions

Why are my Edge Function logs empty when Stripe gets a 401?
Because your code never ran. With verify_jwt on (the default), the platform checks the Authorization header before your function starts. Stripe sends no Supabase JWT, so the platform answers 401 and your console.log lines never execute.
How do I prove the 401 came from the platform and not my code?
Read the response body. Supabase says "Missing authorization header" or "Invalid Token or Protected Header formatting" come from the built-in check. A message you wrote, or an empty body, means your code ran and returned 401 itself. The Invocations view shows each request's status code.
Is verify_jwt = false safe for a Stripe webhook?
Only with Stripe's signature check in your code. Turning it off lets anyone call the function, so verify the Stripe-Signature header against the raw body and answer 400 if it fails. Supabase's own Stripe example does this.
Do the new sb_publishable_ and sb_secret_ keys change this?
Stripe still sends no Supabase credential, so the fix is the same. The new keys are not JWTs. Supabase says to send them in the apikey header, not as a Bearer token, and to authenticate webhooks in your own code.
My webhook runs now, but the database write fails. What then?
Return a non-2xx when the write fails, so Stripe keeps retrying, and log the error. A 200 tells Stripe the event was delivered, and it stops retrying.

Missed events while it was failing? See how to find and replay undelivered events.

With a read-only Stripe key, CheckoutAlarm emails you when Stripe events are still not delivered after about 15 minutes, which is what a platform 401 causes. It doesn't see the status code or your function logs, so Event deliveries and the Invocations view are still where you find the 401.

CheckoutAlarm is new. It only checks sites you prove you own. How to verify

Watch my app free

Docs checked on Oct 20, 2026.