Supabase Edge Function 401 Invalid JWT: the safe fix
A 401 with "message":"Invalid JWT" from a Supabase Edge Function comes from the platform's verify_jwt check, which rejected the request before your code ran. Turning the check off (--no-verify-jwt or verify_jwt = false) clears the error, but it's safe only if the function then verifies the caller itself, for example with withSupabase({ auth: 'user' }) or auth.getClaims(token).
That's the standard advice, and it isn't wrong. In discussion #42534, a developer reported a function that "always returns 401 Invalid JWT BEFORE execution", with nothing in the logs. A Supabase maintainer replied:
"I do recommend use the
--no-verify-jwtflag while developing and deploying"
The reporter found the dashboard's Verify JWT toggle switched on, turned it off, and the 401 went away. Supabase's Edge Function 401 troubleshooting page gives the same fix, and adds that Supabase now recommends turning the built-in check off and handling authentication in your function code.
People skip that second half. If a function relied on the platform to check the token, nothing checks it once the flag is off.
Why does my Supabase Edge Function return 401 Invalid JWT?
Because the platform's verify_jwt check rejected the request before your handler started, which is why your function logs nothing. Supabase's Authorization headers reference says the check returns a 401 when the Authorization header is missing, malformed, or signed with a different key. Recent reports show a code in the body that narrows it down:
{"code":"UNAUTHORIZED_LEGACY_JWT","message":"Invalid JWT"}
{"code":"UNAUTHORIZED_INVALID_JWT_FORMAT","message":"Invalid JWT"}
UNAUTHORIZED_LEGACY_JWTappeared in supabase#47747, filed on 8 July 2026 when valid legacy HS256 user tokens started failing. Supabase's status page logged elevated legacy JWT authorization errors on Edge Functions in some regions that day and mitigated them with a rollback. If valid tokens suddenly fail, check the status page first.UNAUTHORIZED_INVALID_JWT_FORMATappeared in supabase#45993 (May 2026). A dashboard curl example put the publishable key in the bearer header, and publishable keys aren't JWTs. Supabase now says the check accepts the new keys there, and a collaborator closed the issue on 17 October.- New asymmetric signing keys: supabase#42810, still open, reports ES256 user tokens rejected at the gateway. In April a Supabase team member replied that function verification wasn't updated when signing keys shipped, pointed to a CLI fix, supabase/cli#4721 (merged March 2026), and said the hosted platform side wasn't GA yet.
The troubleshooting page lists two more messages from the same check: Missing authorization header and Invalid Token or Protected Header formatting.
To tell which layer sent a 401: a platform rejection never runs your function, so its execution_id in the function edge logs is empty, which is how the troubleshooting page's log query sorts them. A 401 that withSupabase sends carries an x-supabase-server-error header and a message prefixed [@supabase/server]. Anything else came from your own code.
Supabase's own docs disagree about verify_jwt
As of 23 October 2026, Supabase's pages contradict each other:
- The 401 troubleshooting page, last edited in August, says the check only understands the legacy format, so tokens signed with the new asymmetric keys fail it, and recommends turning it off.
- The migration guide says in step 4 that the check only understands the legacy JWT-based API keys and rejects a new key sent as a bearer token. Further down, the same page says the check accepts the new keys on
Authorization: Bearerfor migration compatibility. - The Authorization headers reference says the check validates both legacy HS256 tokens and tokens signed with the new asymmetric keys, and accepts the new API keys.
- The Securing Edge Functions guide, revised on 5 October 2026 in PR #50886, says to leave
verify_jwt = trueon for user-facing functions. It warns: "Turning it off to clear a 401 during development removes the platform's check".
You don't have to settle the argument. If your code verifies the caller, the platform check is only a pre-filter: keep it where it works, turn it off where it gets in the way, and the same callers reach your data either way.
It was never a login check anyway. The Authorization headers page says that, for migration compatibility, the check also passes publishable and secret keys, on either header. A publishable key ships in every client, and a legacy anon key is itself a valid JWT. That's why Supabase Edge Functions are effectively public by default.
The fix that opens the function
The pattern to look for: the function assumes the platform already checked the signature, so it only decodes the token to read the user ID:
import { createClient } from "npm:@supabase/supabase-js@2";
import { decodeJwt } from "npm:jose@6";
// Redeployed with --no-verify-jwt to clear "401 Invalid JWT".
Deno.serve(async (req) => {
const token = (req.headers.get("Authorization") ?? "").replace("Bearer ", "");
// decodeJwt reads the payload. It never checks the signature.
// That was the platform's job, and the flag just turned it off.
const { sub } = decodeJwt(token);
const secretKeys = JSON.parse(Deno.env.get("SUPABASE_SECRET_KEYS")!);
const admin = createClient(Deno.env.get("SUPABASE_URL")!, secretKeys.default);
const { data } = await admin.from("invoices").select("*").eq("user_id", sub);
return Response.json(data);
});
- Warningsupabase/functions/get-invoices/index.ts:5
Edge function without auth validation
Verify the caller inside the function. For user calls, wrap the handler in withSupabase({ auth: 'user' }, handler) from @supabase/server, or read the Authorization header, call supabase.auth.getUser(token) or auth.getClaims(token), and reject when there is no user. For cron or internal calls, use withSupabase({ auth: 'secret' }) (or 'secret:<name>') or compare a dedicated secret header to a server-side secret, and fail closed when that secret is unset. For provider webhooks, verify the provider's signature. The 'none' and 'publishable' modes do not authenticate the caller: a publishable key ships in every client. verify_jwt = true alone is not enough, because the public anon key is a valid JWT.
jose documents that decodeJwt does not validate the signature. While verify_jwt was on, the platform rejected a token signed with the wrong key before this code ran. With the flag off, anyone can build a token by hand and put another user's ID in sub. The function then reads that user's invoices with a secret key that bypasses RLS. Nothing errors, and your own app keeps working.
GuardLayer reports this as supabase/edge-function-no-auth: a handler that never verifies the Authorization header. decodeJwt doesn't count as verification; auth.getUser(), getClaims() and jwtVerify() do. The rule also fires on withSupabase with auth: 'none' or 'publishable' and no other check, and stays quiet on the 'user' and 'secret' modes. It can't see supabase/config.toml or the dashboard toggle, so it treats every handler as if verify_jwt were off.
How do I fix 401 Invalid JWT without opening the function?
Verify the caller in code, then set verify_jwt to match how the function is called. For a function your signed-in users call:
import { withSupabase } from "npm:@supabase/server@1";
export default {
fetch: withSupabase({ auth: "user" }, async (_req, ctx) => {
// ctx.supabase is scoped to the verified caller, so RLS applies.
const { data } = await ctx.supabase.from("invoices").select("*");
return Response.json(data);
}),
};
The wrapper verifies the user's JWT against your project's JWKS, and your handler runs only if that succeeds. ctx.supabase queries as the caller, so your RLS policies on invoices pick the rows and there's no user ID to forge. Leave verify_jwt on, as the Securing guide now says. If the platform still rejects valid tokens (the #42810 case), turning it off no longer opens anything.
One catch: @supabase/server only supports the new API keys and JWT signing keys, and its 'user' mode rejects legacy HS256 user tokens. On a project still on the shared JWT secret, or without the package, verify with supabase.auth.getClaims(token) and reject on error; it checks HS256 tokens with the Auth server and asymmetric ones against the JWKS. Our JWT signing keys post covers the switch.
Cron jobs, pg_net calls and service-to-service traffic have no user JWT. Supabase's scheduling guide has pg_cron send a secret key on the apikey header, read from Vault, with verify_jwt = false on the function and auth: 'secret' in the wrapper. A named key can be rotated without touching your other callers:
[functions.refresh-totals]
verify_jwt = false
import { withSupabase } from "npm:@supabase/server@1";
export default {
fetch: withSupabase({ auth: "secret:cron" }, async (_req, ctx) => {
await ctx.supabaseAdmin.rpc("refresh_invoice_totals");
return Response.json({ ok: true });
}),
};
'secret:cron' accepts only the secret key named cron, and bare 'secret' accepts only the one named default. For the calling side, see the Vault-backed cron.schedule call that replaces a pasted key. Webhooks from Stripe or GitHub send no Supabase credentials. Use auth: 'none', keep verify_jwt = false, and verify the provider's signature in the handler.
Set verify_jwt per function in supabase/config.toml, not with a deploy flag or the toggle. In #42534 the maintainer pointed to other issues where the local config overwrote the platform setting on deploy, and the reporter asked why the toggle sometimes switched itself back on. If a 401 returns after a deploy, compare the two.
Quick self-check
# Functions with the platform check turned off
grep -n -B1 "verify_jwt *= *false" supabase/config.toml
# Handlers that decode a token without verifying it
grep -rn "decodeJwt" supabase/functions
# Wrappers that authenticate nobody
grep -rnE "auth: *['\"](none|publishable)" supabase/functions
Every function in the first list needs its own check in code. To see which layer is sending your 401s, run this in the dashboard's SQL editor on the logs source. It uses the fields from Supabase's troubleshooting query:
select
timestamp,
log_attributes['request.pathname'] as function_name,
case
when log_attributes['execution_id'] = '' then 'rejected_by_platform'
else 'returned_inside_function'
end as source_of_401
from logs
where source = 'function_edge_logs'
and toInt32OrZero(log_attributes['response.status_code']) = 401
order by timestamp desc
limit 50;
FAQ
Is --no-verify-jwt safe?
Only when the function authenticates every caller in code: withSupabase with 'user' or 'secret', auth.getUser() or getClaims() on the token, or a provider signature check. Without one of those, the flag makes the function public.
Why does my Edge Function return 401 with nothing in the logs?
The platform's verify_jwt check rejected it before your handler started. Check the code field in the response body and the request's execution_id in the function edge logs.
Why does my cron job get a 401 from an Edge Function?
A cron job has no user JWT. Supabase's scheduling guide sends a secret key on the apikey header from Vault, with verify_jwt = false on that function and auth: 'secret' (or 'secret:<name>') in the wrapper.
Does verify_jwt = true mean only signed-in users can call my function?
No. For migration compatibility the check also accepts publishable and secret keys, and a legacy anon key is a valid JWT. Anyone can copy the key your client ships, so verify the user in code.
Catch this before it ships — free
Scan your files for this and 35 other Next.js + Supabase issues, with the exact fix inline. Connect GitHub and GuardLayer checks every push too — free on public repos.
No signup, no card. Raw files aren’t kept; only the report is saved.