← All field notes

Every Supabase key, what it does, and which ones can be public

The legacy keys are being retired, and they can no longer be rotated. Here is what replaces them, and how to tell which one you shipped.

Aug 2, 2026 · 10 min read


Field Notes from Thunkle, a studio that takes AI-built apps from prototype to secure, production-ready software.

I audit apps built with AI tools. Most of the exposed credentials I find are Supabase keys, and most of them are not a problem.

That is the part people get wrong in both directions. Some panic about a key that was designed to be published. Others ship the one that grants full database access and never notice, because nothing breaks.

There are four keys. Two of them are being retired.

The deadline

Supabase is replacing the original anon and service_role keys. Their own timeline is blunt about what happens at the end:

"Legacy API keys will be deleted and removed from the Docs / Dashboard. You have to migrate to use the new API keys by this point or your app will break."

That is scheduled for late 2026, marked as to be confirmed.

There is an earlier milestone that matters more:

"Projects restored from 1st November 2025 will no longer be restored with the legacy API keys. New projects no longer have anon and service_role available for use."

So if your project was created after November 2025, you already have the new keys and there is nothing to do. If it was created before, you are on legacy keys and you have a deadline.

Think about who that is. Nearly every app built during the 2025 rush of Lovable, Bolt and Replit projects predates that cutoff. Those apps are running legacy keys. Most of their owners have never opened the API settings page. The app stops working when the keys are deleted.

The four keys

| Key | Format | Browser safe | What it grants | |---|---|---|---| | Publishable | sb_publishable_... | Yes | Low privilege. Access controlled by row level security through the anon and authenticated roles | | Secret | sb_secret_... | No | Full access to your project's data. Bypasses row level security | | anon (legacy) | JWT | Yes | Legacy version of the publishable key | | service_role (legacy) | JWT | No | Legacy version of the secret key. Bypasses RLS via the BYPASSRLS attribute |

Source: Supabase, Understanding API keys.

Two of these belong in your frontend. Two of them, if they reach a browser, mean your database is already compromised.

A public key is not a leak

The publishable key is meant to be shipped. It sits in your JavaScript bundle. Anyone who opens developer tools can read it. That is the design.

The key identifies your project. It is not what protects your data.

Row level security is what protects your data. The key gets a request as far as the anon or authenticated role, and the policies you write on each table decide what those roles can read and write.

So the key is exactly as safe as your policies are. If every table has policies, publishing it costs you nothing. If a table has RLS switched off, that same public key reads the whole table. So does anyone else who found it.

This is the most common serious finding in the audits I do. Not a stolen credential. A published one, working as designed, in front of a table that was never given a rule.

The secret key: full database access, no policies

The secret key bypasses row level security completely. It does not respect your policies because it is not supposed to. That is its job. It exists so your server can act on behalf of the system rather than a signed-in user.

If it reaches a browser, every protection you wrote is gone at once. Not weakened. Gone. Anyone who reads it out of your bundle can read, change or delete any row in your database.

There is no partial version of this finding. Treat the key as already compromised, because every visitor to your site has been able to read it.

Why the format changed

Here is the problem the new keys solve.

The legacy anon and service_role keys are both JSON web tokens. Both are long strings of the same characters. Side by side, you cannot tell them apart by looking. The only thing separating a key that belongs in every visitor's browser from a key that grants full database access is one field inside the token.

I built a security scanner for Framer sites, which is where I run into this constantly: Framer handles the front end, so anything with a database behind it has Supabase wired in from the browser, and the key is sitting in the page. The scanner decodes the token and branches on the role claim. service_role is reported as critical. anon is not reported at all, because reporting it would be wrong. A tool that flags your publishable key has not found anything. It has taught you that its output is noise, and the next thing you skip past will be the real one.

The new format removes the ambiguity. sb_publishable_ and sb_secret_ are different at a glance, to a person and to a tool. Supabase also added browser detection, so a secret key used from a browser returns HTTP 401 instead of quietly working.

Do not read that as a safety net. The check matches on the User-Agent header, and Supabase's own docs say so plainly: it "does not mean that attackers will not use it with other tools." Anyone who copies your key out of a page and sends it from curl gets straight through. The 401 stops your own code from working by accident. It does not stop the person who found the key.

That is a real improvement, and it is worth being clear about why. The old design made a catastrophic mistake and a harmless one look identical.

How to tell which one you shipped

On the new keys, read the prefix. sb_publishable_ is fine in your frontend. sb_secret_ is not.

On legacy keys, the prefix will not help. You have to decode the token and read the role field.

Do that locally, not in an online JWT decoder. If the key turns out to be service_role, you have just pasted full access to your database into somebody else's website. In your browser console:

JSON.parse(atob(key.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))).role

It says anon or service_role. That is the whole test.

The split and the two replace calls are not decoration. A JWT is three base64url segments joined by dots, so atob on the whole string fails, and base64url uses - and _ where atob expects + and /. Plenty of tokens happen to contain neither character, which is why the naive one-liner works right up until the day it does not.

Then check where each one is used. The publishable or anon key belongs in client code. The secret or service_role key belongs on a server, in an environment variable, never in a repository, and never in anything sent to a browser.

If the wrong key is already public

This is where the two key systems stop being equivalent, and it is the strongest practical argument for migrating.

On the new keys, you replace it. Create a second secret key in Settings, API Keys, move everything over to it, then delete the compromised one. The publishable key is untouched, nobody is signed out, and there is no downtime. Deleting a secret key is permanent, so do it in that order.

On legacy keys, you cannot. Supabase's own troubleshooting documentation is explicit: "it is no longer possible to rotate the legacy anon, service and JWT secrets." There is no button. An exposed service_role key stays exposed until you migrate off it.

That is worth sitting with. If you are on legacy keys and your service_role key is in a browser bundle today, migration is not a piece of housekeeping with a 2026 deadline. It is the only way to revoke the key, and it is this week's job.

Historically the fix was to regenerate the project's JWT secret, which signed both legacy keys and therefore invalidated every session in one go. Supabase describes the effect as all current secrets being "immediately invalidated, and all connections using them will be severed." That route is closed now, and the replacement path is the new keys, which is the point of the whole exercise: individually revocable credentials instead of two tokens welded to one secret.

Then check your row level security policies while you are there. An exposed service_role key usually means nobody was thinking about database access rules at the time, so the policies are often missing too.

Migrating off the legacy keys

Supabase's migration guide covers the process:

  1. Create the new keys in Settings, API Keys
  2. Replace the anon key with the publishable key in client code
  3. Replace service_role with the secret key on the server
  4. Update Edge Functions to read the new environment variables
  5. Confirm nothing still uses the old keys
  6. Deactivate the legacy keys

Both sets work at the same time, so you can move gradually. And deactivating the legacy keys is reversible. If you miss a client, turn them back on. That matters more than it sounds, because fear of breaking production is the main reason this gets postponed.

Five things in the migration are easy to get wrong, all documented:

  • Neither new key goes in an Authorization: Bearer header. Send them on the apikey header only. Many Supabase clients pass the key on both by default, and the platform then tries to parse it as a JWT and rejects the request with Invalid JWT. That error message sends people looking in the wrong place for hours.
  • Database Webhooks and pg_net calls need the same change.
  • Do not hardcode secret keys in SQL or webhook configuration. Use Vault.
  • Edge Functions cannot verify the new keys in Authorization headers, so set verify_jwt = false. That makes the function publicly invokable by anyone with the URL, so the authorisation check has to move into the function body. The docs say to "authorize the request in your own code", and this is the step people skip, because the function keeps working perfectly whether or not you do it.
  • Public Realtime connections are limited to 24 hours unless the connection is upgraded with user-level authentication through Supabase Auth or a supported third-party provider.

What to do this week

Open your Supabase dashboard and go to Settings, API Keys. If you see anon and service_role, your project predates November 2025 and you have a migration to do.

While you are there, three checks:

Confirm every table has row level security enabled, then look at the policies. Enabled is not the same as protected. A table with a policy of USING (true) is readable by anyone holding your public key, which is everyone, and the dashboard will still show RLS as on. The same goes for a table exposed through a SECURITY DEFINER view, which runs as its owner and bypasses policies entirely. Neither shows up as a warning.

Search your frontend code and your deployed bundle for service_role and sb_secret_. Search the built output, not just the source, because environment variables get inlined at build time and that is usually how it happens. If you find one, see the section above: replaceable on the new keys, not replaceable on legacy.

Decode the key that is actually in your client bundle and confirm the role says anon. Not the one in your .env file. The one that shipped.

Those take a few minutes. They will not tell you whether the policies you wrote are correct, or whether a later change reopened something you had already closed. Finding an exposed key and knowing your access rules hold are different problems. On AI-built apps, the second is where most of the work is.

If you would rather not check it yourself

That second problem is most of what I do. I audit apps built with AI tools, work through the access rules account by account, and send back a report with every finding, where it lives and what to change. Fixed price, from $750. That is the audit we do, and you can request one here.


Sources

Working on something like this?

If you're migrating, securing or building an AI-made app, Thunkle can help. Get a fixed quote in 24 hours.

Get a quote