
Supabase Legacy API Keys Deprecated: How to Migrate
Supabase is retiring the legacy anon and service_role keys by the end of 2026. Swap anon for the publishable key (sb_publishable_…) in client code, swap service_role for a secret key (sb_secret_…) on the server, then deactivate the legacy keys. Both systems run side by side until you flip that switch, so you can move one caller at a time. The migration guide makes it sound like a find-and-replace. Mostly it is. I wanted to know where it isn't, so I audited every Supabase project and repo I touch and threw a few dozen requests at a live project to see which headers, user agents and Edge Function settings actually break.
What Is Changing With Supabase API Keys?
The old keys are long-lived JWTs signed with your project's JWT secret. The new ones are short opaque strings. The mapping is one to one:
- anon → publishable key. Browser, mobile app, anything you ship. Same
anonPostgres role, so RLS behaves exactly as before. - service_role → secret key. Servers, workers, Edge Functions. Same
service_rolerole, still bypasses RLS.
The original announcement lays out the timeline: new keys in mid-2025, migration reminders from November 2025, and deletion of the legacy keys in "late 2026 (TBC)". Supabase's API keys docs now say "by the end of 2026". As of today there's no published day. Plan for December and you won't be surprised.
Don't wait for the keys to expire. They won't. I decoded the legacy keys on a project I created on September 24, 2026. Both carry an exp of September 24, 2036. Nothing on your side times out. The keys work right up until Supabase turns them off, and then every caller still holding one gets a 401 on the same day.
How Many of My Projects Were Still on Legacy Keys?
All of them. I run Supabase for my own products and for client apps across a couple of orgs, so I pulled every active project through the Management API:
import json, os, urllib.request
TOKEN = os.environ["SUPABASE_ACCESS_TOKEN"]
def get(path):
req = urllib.request.Request(
"https://api.supabase.com/v1" + path,
headers={"Authorization": f"Bearer {TOKEN}"},
)
return json.load(urllib.request.urlopen(req))
for p in get("/projects"):
if p["status"] != "ACTIVE_HEALTHY":
continue
types = {k["type"] for k in get(f"/projects/{p['id']}/api-keys")}
legacy_on = get(f"/projects/{p['id']}/api-keys/legacy")["enabled"]
print(p["name"], "legacy on" if legacy_on else "legacy off",
"has new keys" if "secret" in types else "NO new keys")- 10 of 10 active projects still had legacy keys enabled.
- 9 of 10 already had publishable and secret keys. They were created alongside, and almost nothing used them. The one without them is the oldest, from October 2025.
- Three projects created in September 2026 still came with legacy keys. The announcement says new projects stopped getting them in November 2025. Mine did anyway, so don't assume a new project is clean.
Then the code. I scanned 43 local repos that depend on supabase-js. Every one references a legacy variable name like SUPABASE_ANON_KEY or SERVICE_ROLE_KEY somewhere. 19 of them have real keys in a local .env, and 18 of those 19 still hold eyJ… JWTs, 67 values in total. Five new-style keys, in two repos. One repo is fully migrated.
Having the new keys isn't the same as using them. The dashboard creating them for you is what makes this easy to put off. The env files are where the real work lives, and each one is a Vercel project, a CI secret or an n8n credential somewhere.
What Breaks When You Swap the Keys?
I used one of my own projects, revealed its keys through the Management API and hit the REST, Auth and Storage endpoints with every combination I could think of. October 18, 2026, no client data involved:
| Request | Result |
|---|---|
| Publishable key on apikey | 200 (same as anon) |
| Publishable key, Authorization: Bearer only | 401 "No API key found in request" |
| service_role key, Authorization: Bearer only | 401 "No API key found in request" |
| Secret key on apikey + Bearer (what supabase-js sends) | 200 |
| Secret key, User-Agent: curl / node / none | 200 |
| Secret key, User-Agent: Mozilla/5.0 | 401 "Forbidden use of secret API key in browser" |
| service_role key, Chrome User-Agent | 200 (no browser check) |
| Made-up sb_secret_ key | 401 "Invalid API key" |
Bearer-only calls fail, but they already did
Several write-ups say you can't put the new keys in Authorizationanymore. That's half right. A request with only a Bearer header fails, but the legacy service_role key fails the exact same way. The gateway always wanted the apikey header. If your curl scripts and n8n HTTP nodes work today, they send it already.
supabase-js doesn't need an upgrade
I wrapped fetch in supabase-js 2.118.0 to log the headers. With all three key types it sends the key on apikey and as Authorization: Bearer. The REST and Storage calls worked with the publishable and secret keys anyway. For most Next.js apps the code change is literally the key string.
The browser block is a user-agent check
A bare Mozilla/5.0 user agent was enough to get the secret key rejected. That's the feature working as designed: leak the key into a bundle and the browser can't use it. But it also catches server code that pretends to be a browser. A Playwright job, a scraper that writes results to Supabase, an HTTP client someone set to a Chrome UA two years ago to get past a WAF. Those all worked with service_role, which has no such check. They'll 401 with the secret key. Grep for User-Agent in anything server-side before you switch it.
Do Edge Functions With verify_jwt Still Work?
This is the part the migration guide is most cautious about. It warns that a new key on the Bearer header gets parsed as a JWT and rejected with Invalid JWT. I deployed a throwaway function with verify_jwt = true that only reports which headers arrived, called it, then deleted it:
| Call to a verify_jwt = true function | Result |
|---|---|
| No key at all | 401 UNAUTHORIZED_NO_AUTH_HEADER |
| Legacy anon or service_role as Bearer | 200 |
| Publishable key on apikey, or as Bearer | 200 |
| Secret key on apikey, or as Bearer | 200 |
| Made-up sb_secret_ or sb_publishable_ key | 401 "Invalid API key" |
No Invalid JWTanywhere. The platform check now recognizes the new keys on either header, which the API keys page calls a migration-compatibility mode. Good news for uptime. It's also the trap.
verify_jwt now means "some valid key for this project", not "a trusted caller". The publishable key sitting in your JavaScript bundle passes it. With legacy keys that was also true of the anon key, which is why functions called by cron jobs or pg_net often add a second check, comparing the Bearer token to SUPABASE_SERVICE_ROLE_KEY. Move the caller to a secret key and that comparison fails closed, which is annoying. Delete the comparison during cleanup and it fails open, which is worse. Check the key explicitly:
const apikey = req.headers.get("apikey") ?? "";
const secrets = JSON.parse(Deno.env.get("SUPABASE_SECRET_KEYS") ?? "{}");
// verify_jwt let this request in. That only proves the key belongs
// to this project. It could be the publishable key from your bundle.
if (apikey !== secrets["default"]) {
return new Response("forbidden", { status: 403 });
}SUPABASE_SECRET_KEYS and SUPABASE_PUBLISHABLE_KEYSwere both present in the function's environment, as JSON objects keyed by name, next to the legacy SUPABASE_ANON_KEY and SUPABASE_SERVICE_ROLE_KEY. Supabase's preferred route is the @supabase/server SDK with auth: 'secret', which does this check for you. Either works. Skipping it doesn't.
How Do I Migrate a Project Without Downtime?
1. Inventory first
Run the audit script above, then grep repos and env files for values starting with eyJ. Don't forget the places that never show up in a repo: Vercel env vars, GitHub Actions secrets, n8n credentials, Database Webhooks and pg_netcalls inside Postgres, and mobile builds already on people's phones. There's no usage meter for legacy keys, so this list is the only safety net you get.
2. Rename the variables, don't reuse them
# browser-safe
NEXT_PUBLIC_SUPABASE_URL=https://your-ref.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_...
# server only, never prefix
SUPABASE_SECRET_KEY=sb_secret_...Not one of my 19 repos had a new key stored under an old name, and I'd keep it that way. When the variable is called ANON_KEY, nobody can tell from the name which system it's on, and a stale eyJ value survives every review.
3. Move the server side first
Backend callers are the ones you control and can roll back in minutes. Use a separate secret key per service. If the n8n box leaks its key, you delete that one key and the Next.js app never notices. With service_role you had one key, and rotating it meant rotating the JWT secret for the whole project. Same idea as the per-client service tokens I use for n8n webhooks behind Cloudflare Access, and keep the keys out of logs the way I do in n8n execution data redaction.
4. Then the clients
Swap the publishable key into web builds and redeploy. Native apps are the slow part: old app versions keep sending anonuntil users update, and that's the one case where you might keep legacy keys on until the very end.
5. Deactivate and watch
Turn off the legacy keys in Settings → API Keys and watch your logs for 401s for a day. Deactivation is reversible, so it's a cheap rehearsal. Much cheaper than finding out the day Supabase removes the keys for everyone.
Is This Worth Doing Before the Deadline?
Yes, and not only because of the deadline. The secret key is a better credential than service_role: one per service, deletable on its own, useless in a browser. Those are things I wanted years ago. The publishable key is a lateral move, same role, same RLS, so that half of the work is pure chores.
My order for the next few weeks is the client production apps first, then my own products, then the sandboxes. I just went through the same exercise for the Vercel Node.js 20 cutoff, and the lesson carries over: the deadline isn't what breaks you. The caller you forgot about is. If you're wiring Supabase into automations, my Supabase + n8n lead database build shows where those credentials tend to hide.
How I checked this: Supabase Management API calls across the projects my access token can see, a grep of 43 local repos and their .env files for key prefixes (values never printed), and live requests against one of my own projects on October 18, 2026 with supabase-js 2.118.0, curl-style clients and a throwaway Edge Function that was deleted afterward. Timeline dates come from Supabase's docs and the GitHub announcement, read the same day.
Supabase API Keys FAQ
When do Supabase legacy anon and service_role keys stop working?
Supabase says the legacy keys are deprecated by the end of 2026 and lists the removal as late 2026, still marked TBC, with no exact day published as of October 18, 2026. Until then they keep working unless you deactivate them yourself in Settings > API Keys.
Do I need to upgrade supabase-js to use the publishable key?
Not for basic database and storage calls. supabase-js 2.118.0 sends the key on both the apikey and Authorization headers, and REST and Storage accepted that for publishable and secret keys in my tests. Swapping the key string was enough.
Why does my Supabase secret key return 401 Forbidden use of secret API key in browser?
Supabase blocks secret keys when the User-Agent looks like a browser. A bare Mozilla/5.0 was enough to trigger it, so server code that spoofs a browser user agent, such as a Playwright scraper or an HTTP client set to look like Chrome, gets the same 401. Use the default Node or curl user agent on server calls.
Does verify_jwt still work with the new Supabase API keys?
It lets them through. An Edge Function with verify_jwt = true accepted the publishable and secret keys on either the apikey or Authorization header and rejected fake keys. It does not tell your code which key was used, so a function that should only accept the secret key has to check that itself.
Is the publishable key less secure than the anon key?
No. It maps to the same anon Postgres role, so your Row Level Security policies behave exactly the same. The gain is on the server side: secret keys can be created per service, deleted one at a time and blocked from browsers, which the single service_role JWT never allowed.
Want a Second Pair of Eyes on the Migration?
I build Next.js and Supabase apps and the automations around them. If you have a pile of projects on legacy keys and want them moved without a 2 a.m. outage, send me the list and I'll map every caller before anything gets switched off.
Related Posts
Full-Stack
Vercel Node.js 20.x Discontinued Error: How to Fix It
Since October 1, 2026 Vercel refuses new builds on Node.js 20. Tested on a throwaway project: engines 20.x, ^20.11.0 and >=18 <21 fail in under a second, >=20 quietly runs Node 24, engines overrides Project Settings, .nvmrc is ignored, and the API still accepts 20.x.
Full-Stack
Vercel Workflow DevKit: Durable AI Agents Guide
Every tool call an agent makes becomes a persisted, retryable step, so a crash, a redeploy, or a pause that lasts weeks resumes exactly where it left off instead of starting over. How DurableAgent, "use step", and createHook actually work — and where n8n still wins.
Full-Stack
Docker on macOS: Why Bind Mounts Corrupt Your Databases
Docker Desktop runs your containers in a Linux VM, and every bind-mounted host path crosses that boundary over a file-sharing protocol that does not honor fsync, ordering or locking. That is how a Mongo data file on my external SSD ended up internally inconsistent. Why named volumes are the only safe home for a storage engine, how to migrate without data loss, and how to put Docker itself on an external drive the right way.