Fix "Legacy API keys are disabled" in Supabase
Legacy API keys are disabled means a client sent an old anon or service_role JWT to a project whose legacy keys are switched off. Give that client a publishable or secret key; re-enabling the old pair is a stopgap at best, and wrong if a key leaked.
The response body, unwrapped from the CLI error that quoted it in supabase/cli#3893:
{"message":"Legacy API keys are disabled","hint":"Your legacy API keys (anon, service_role) were disabled on 2025-07-22T16:28:01.624439+00:00. Re-enable them in the Supabase dashboard, or use the new publishable and secret API keys."}
That thread was a supabase gen types bug, fixed server-side by 6 August 2025, but the message is still live: a PR merged on 3 October 2026 reported it as 401 Legacy API keys are disabled, from end-to-end tests with a hardcoded anon JWT. Compare the hint's timestamp with your deploys and last green CI run.
What does "Legacy API keys are disabled" mean?
It means the request carried a legacy key, a long JWT starting with eyJ, to a project whose legacy keys are switched off. Supabase's API keys docs say legacy keys "stay valid until you disable them in the Settings > API Keys section of the Dashboard, which is a separate step." Two ways we know of:
- Someone finished the migration. Deactivation is step 6 of Supabase's migration guide. Step 5, checking that nothing still uses the old keys, warns: "There's no automatic usage indicator, so this is a manual check." Whatever that check missed now gets this error.
- The project was paused and restored. Supabase's timetable in discussion #29260 says "Projects restored from 1st November 2025 will no longer be restored with the legacy API keys." We haven't confirmed which error those projects return. The fix is the same, and there may be nothing to re-enable.
The hint names both keys, so assume both are off. A publishable key replaces anon and a secret key replaces service_role; publishable vs secret keys covers the migration order. Invalid API key is a different failure: that error usually means the key doesn't belong to the project at that URL.
Should I re-enable the legacy API keys?
Only as a short stopgap, and not at all if they were disabled because a key leaked. Supabase's own leaked-key procedure allows it:
"For a legacy
anonorservice_rolekey, deactivate the legacy keys in the same Dashboard section instead. Deactivation is reversible, so you can re-enable them if you find a client you missed." — Supabase docs, Rotate a leaked or compromised key
Supabase's rotation troubleshooting page adds that if you haven't migrated to asymmetric JWT signing keys, "it is no longer possible to rotate the legacy anon, service and JWT secrets." Our inference from the two: on such a project, deactivation is the only off switch the docs give you for a leaked service_role JWT. Re-enabling to unblock a forgotten cron job turns the leaked key back on for everyone holding a copy, RLS bypass intact, and #29260 notes legacy JWTs "had 10-year expiry duration".
Nor is re-enabling scoped to the client you forgot: we haven't verified that anon can come back without service_role, so plan as if it can't. And a commenter on cli#3893 reported being unable to re-enable the keys after switching.
Even without a leak, it buys little time: #29260 schedules deletion for "Late 2026, TBC" and warns "You have to migrate to use the new API keys by this point or your app will break."
Where the legacy key is still hiding
The migration guide's easy-to-miss callers run from shipped app versions to "Cron jobs, workers, and pg_net calls or Database Webhooks". In a Next.js + Supabase repo, check:
- The browser bundle.
NEXT_PUBLIC_SUPABASE_ANON_KEYis inlined at build time; per the Next.js docs, once built "your app will no longer respond to changes to these environment variables." Rebuild every environment after changing it. - Edge Functions. The API keys docs say
SUPABASE_ANON_KEYandSUPABASE_SERVICE_ROLE_KEY"still exist in the runtime, but they carry the legacy keys". A user hit exactly this in supabase#37648, later closed for inactivity. pg_netcalls and Database Webhooks. Per the migration guide, they "usually send theservice_rolekey on theAuthorization: Bearerheader". Secret keys aren't JWTs and fail there; send them on theapikeyheader.- Tests and scripts. E2E setup files and seed scripts with a pasted anon JWT, like the PR above.
The fix that makes it worse
The nightly cleanup job breaks, so someone swaps the JWT in its migration for the new secret key, exactly as the migration guide's before/after example does. It works, and the key is now committed:
-- "Legacy API keys are disabled" broke the nightly cleanup,
-- so the old service_role JWT was swapped for the new secret key.
select cron.schedule(
'purge-expired-sessions',
'0 3 * * *',
$$
select net.http_post(
url := 'https://your-project.supabase.co/functions/v1/purge-sessions',
headers := jsonb_build_object(
'Content-Type', 'application/json',
'apikey', 'sb_secret_Q7mVd2LkR9tWx4ZpB8nH3cJ5yF1aGs6'
)
);
$$
);
- Criticalsupabase/migrations/20261013090000_cleanup_cron.sql:11
Hardcoded secret in source
Move the secret to an environment variable, purge it from git history (e.g. git filter-repo / BFG), and rotate it at the provider.
The key now sits in git history and, in plain text, in the command column of cron.job. The guide then warns: "Don't hardcode a secret key in SQL or a webhook configuration, where it's stored in plain text. Store it in Vault". A secret key bypasses RLS just as service_role did, so a committed one is the same exposure with a new prefix, except that you can delete it.
The correct fix: read the key from Vault
Create the secret once in the SQL editor or psql, not in a migration, so the value never lands in a file:
select vault.create_secret('<secret key for this job>', 'cron_secret_key');
Then reschedule the job to read the key at call time:
-- supabase/migrations/20261013100000_cleanup_cron_vault.sql
select cron.unschedule('purge-expired-sessions');
select cron.schedule(
'purge-expired-sessions',
'0 3 * * *',
$$
select net.http_post(
url := 'https://your-project.supabase.co/functions/v1/purge-sessions',
headers := jsonb_build_object(
'Content-Type', 'application/json',
'apikey', (select decrypted_secret from vault.decrypted_secrets where name = 'cron_secret_key')
)
);
$$
);
Give the job its own secret key; the docs recommend "a separate secret key for each backend component". Treat the committed key as leaked: move its other users to a new key, then delete it in Settings > API Keys. Git history keeps the value.
The receiving function needs verify_jwt = false and its own key check, since an Edge Function with verify_jwt off and no check answers anyone with the URL. @supabase/server does the check with auth: 'secret:<key name>'; plain 'secret' expects the key named default. If you re-enabled the legacy keys as a stopgap, deactivate them once every caller is fixed.
What GuardLayer catches here, and what it can't
GuardLayer reads committed code, config and migrations, and flags an sb_secret_ key pasted into any of them, as above. Legacy JWTs are only half covered: a service_role JWT pasted into this migration or a TypeScript client is flagged (supabase/service-role-jwt-literal, critical), but an anon JWT is public by design and produces no finding, so a clean scan doesn't prove you've migrated. Nor can it see dashboard-created Database Webhooks, Edge Function secrets, Vercel environment variables or third-party tool configs.
Quick self-check
# Legacy JWTs (anon and service_role both start with eyJ)
git grep -nE "eyJ[A-Za-z0-9_-]{20,}\.eyJ"
# Legacy env var names (check what each holds)
git grep -nE "SUPABASE_(ANON|SERVICE_ROLE)_KEY"
# Secret keys pasted into SQL
git grep -n "sb_secret_" -- "*.sql"
Then the database, where Database Webhooks store their headers as trigger arguments:
select jobid, schedule from cron.job
where command like '%eyJ%' or command like '%sb_secret_%';
select tgrelid::regclass as table_name, tgname from pg_trigger
where pg_get_triggerdef(oid) like '%eyJ%'
or pg_get_triggerdef(oid) like '%sb_secret_%';
Fix every hit that holds a legacy key or a plain-text secret key.
FAQ
When will Supabase delete the legacy API keys? There's no firm date. As of 13 October 2026, discussion #29260 lists "Late 2026, TBC" for when legacy keys "will be deleted and removed from the Docs / Dashboard."
Why does my Edge Function say "Legacy API keys are disabled"?
It's probably reading SUPABASE_ANON_KEY or SUPABASE_SERVICE_ROLE_KEY, which carry the legacy keys. Read SUPABASE_PUBLISHABLE_KEYS or SUPABASE_SECRET_KEYS instead. Both are JSON objects keyed by name, and the keys the migration creates are named default.
Does a restored Supabase project still have its legacy keys? Not if it was restored on or after 1 November 2025, per Supabase's timetable, which warns such projects "risk being broken". Move every client to the new keys as soon as you restore one.
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.