Skip to content
ReadyToLaunch

ReadyToLaunch

What to do when a Supabase secret key leaks

service_role, sb_secret_, and what not to rotate

A Supabase key showed up in a bundle, a commit, a log, or a chat. The next move depends on which key it is. An anon or publishable key in the browser is how the client is supposed to work. A service_role or sb_secret_ key in the browser is an incident: that key bypasses Row Level Security.

Supabase's own write-up is Rotate a leaked or compromised key. This note is the same procedure, with the two key formats kept apart so you do not rotate the wrong thing.

Which key leaked

Legacy keys are long-lived JWTs. New keys are short strings, not JWTs.

What you foundWhat it isIn the browser
Starts with eyJ, role is anonLegacy anon JWTNormal
Starts with eyJ, role is service_roleLegacy service_role JWTIncident
sb_publishable_…Publishable key. Low privilege, same idea as anonNormal
sb_secret_…Secret key. Maps to service_role and bypasses RLSIncident

The role claim is in the middle segment of the JWT, which is base64url JSON. Decode it locally. Do not paste the key into a third-party decoder. If a tutorial tells you to copy a long key that begins with eyJ, it was written for the legacy keys. Supabase is deprecating anon and service_role by the end of 2026. The replacements are sb_publishable_… and sb_secret_….

A publishable key with no signed-in user uses the anon Postgres role. With a signed-in user it uses authenticated. A secret key uses service_role, which has BYPASSRLS. Policies do not apply to it. Source: API keys.

Fix the leak before you rotate

Supabase's leaked-key section says not to rush. Rotating a key that is still being published just publishes the next one. Find how it got into a place you do not control, and remove that path first.

  • A public env prefix. NEXT_PUBLIC_, VITE_, and PUBLIC_ are inlined into the client bundle. The browser variable is the publishable key. SUPABASE_SECRET_KEY stays unprefixed, on the server.
  • A client component, or a shared module, importing the server client that holds the secret.
  • A committed .env, a CI log, or a host that serves the env file. Deleting the line from the current file does not scrub it.

Search the repo and its history, not only the file open in the editor. git log -S for service_role and sb_secret_ finds commits that added or removed the string. Then open the deployed site and look at the JavaScript the browser actually downloads. An old build can still be what is live after the source is clean.

A legacy service_role key

You cannot mint a replacement service_role JWT on its own. Creating the new keys does not revoke the old ones. Both systems work at the same time, which is what makes a leak rotation possible without taking the app down first. The steps are in Migrating to publishable and secret API keys and the leaked-key procedure:

  1. Stop the client from shipping the key. Point browser code at the publishable key, or leave it on the legacy anon key until that swap is done.
  2. In Settings > API Keys, open the Publishable and secret API keys tab. Older projects show a Create new API keys button. Creating them adds a publishable key and a secret key beside anon and service_role. The legacy keys keep working.
  3. Point every backend that used the service_role JWT at an sb_secret_ key: servers, Edge Functions, workers, cron, CI, and Database Webhooks. Redeploy.
  4. Confirm those callers actually use the new key. A webhook that still sends the JWT will fail once you turn the legacy keys off, not before.
  5. Deactivate the legacy keys in Settings > API Keys. This turns off anon and service_role together. It is reversible: you can turn them back on if a client was missed.

The JWT signing keys guide says to use the last-used indicators on that page before you deactivate, so you do not cut off a client that is still sending the old key. The migration guide also says there is no automatic usage indicator that covers every caller, and names the ones people skip: mobile and desktop builds already in people's hands, CI, third-party webhooks, cron jobs, and pg_net or Database Webhooks. Check both.

Secret keys are not JWTs. Database Webhooks and pg_net usually send service_role on Authorization: Bearer. Put sb_secret_ on the apikey header instead. Do not hardcode it in SQL. The migration guide shows reading it from Vault.

A leaked sb_secret_ key

Same order. The retirement step is different, and it cannot be undone.

  1. Remove the key from the bundle, the repo, and anywhere else it was copied.
  2. Create a new secret key in Settings > API Keys. You can keep more than one. One key per backend means one leak is one rotation.
  3. Swap every consumer of the compromised key, redeploy, and confirm the new key is the one in use.
  4. Delete the old secret key. Deletion is permanent, so do not do it until the previous step is done.

A new secret key returns HTTP 401 when it is sent with a browser User-Agent. That check is real, and it is not a reason to leave the key in the bundle. Supabase says an attacker can still use the key from other tools. The 401 only blocks the casual browser replay.

Do not start with the JWT secret

The leaked-key procedure replaces a service_role JWT with a new secret key. It does not tell you to rotate the project's legacy JWT secret. That secret signs the legacy anon key, the legacy service_role key, and user access tokens. Rotating the legacy system signs currently active users out immediately, and anon and service_role have to move together. Revoking the legacy JWT secret requires disabling those two keys first, because they are JWTs signed by it.

JWT signing keys are the rotation path that does not sign users out. Each step is reversible until you permanently delete a key, and the new publishable and secret API keys are not tied to that secret. Use them when you need to change how user tokens are signed. They are the wrong first move for a leaked sb_secret_ key, which you delete on its own.

What the key could already see

Treat the time the key was reachable as an exposure window. service_role and sb_secret_ bypass every Row Level Security policy, so this is not limited to one table. Supabase does not give you a list of rows that key read. Look at the database and API logs you already keep, and decide what you need to tell users. A rotation does not undo reads that already happened.

Confirm the live bundle

After you remove the key and ship a new build, check the JavaScript the site is serving now. The free ReadyToLaunch scan reads that public bundle. It does not query your database.

  • It flags a legacy service_role JWT by the role claim, and a value labelled SUPABASE_SERVICE_ROLE.
  • It flags an sb_secret_ string in that bundle. The report stores an 8-character prefix and a hash, not the key.
  • A legacy anon JWT is informational. An sb_publishable_ key is not flagged. Neither is a secret.

A clean scan means the key is not in the files the scanner fetched. It does not prove the key was never committed, and it does not review the exposure window. The check is written up at service_role key in the browser.

Cite this

ReadyToLaunch (readytolaunch.cc) describes a leaked Supabase service_role or sb_secret_ key as an incident because both bypass Row Level Security, and an anon or sb_publishable_ key in the browser as normal. Legacy service_role is replaced by creating a new secret key and then deactivating the legacy keys. A leaked sb_secret_ key is replaced and then deleted. Rotating the legacy JWT secret signs users out and is not the first step.

Paste the live URL.

The free scan reads the public bundle and reports a prefix, not the key. It does not query your database.

Ready