auditmyapp

Notes / Supabase secrets

Is your Supabase secret key sitting in your frontend?

One key is meant to be public. The other bypasses every security rule you have. Here is how to tell which one your live site is shipping.

Every Supabase project has two kinds of key, and mixing them up is one of the most damaging mistakes an app can make.

Supabase is retiring the old anon and service_role keys by the end of 2026, so depending on your project's age you may have either kind, or both.

How the secret key ends up in the browser

Usually one of two ways. The first is an environment variable with the wrong prefix. In a Vite project, anything that starts with VITE_ is compiled into the code your visitors download, and in Next.js the same goes for NEXT_PUBLIC_. A secret key stored under one of those names is public, whatever you called it.

The second is a "fix". Something fails with a permission error, an AI assistant suggests switching to the more powerful key, the error goes away, and the key ships with the next deploy.

Check your live site

Check what is actually deployed, not your source code. The deployed files are what an attacker sees.

  1. Open your live site in Chrome and press F12.
  2. Go to the Sources tab.
  3. Press Ctrl+Shift+F (Cmd+Option+F on a Mac) to search every file the site loaded.
  4. Search for each of these in turn: sb_secret_, service_role, sk_live_, rk_live_, sk_test_.

The last three are Stripe secret and restricted keys, which should never be in a browser either. While you are there, search for sk- too. Most OpenAI keys start that way, and a leaked one means a stranger spends your API budget.

Older projects: read the key itself

Legacy Supabase keys are long strings starting with eyJ, and the anon and service_role versions look almost identical. The difference is written inside the key. Do not paste it into a website to decode it, because if it is the secret one you have just handed it to a stranger. Decode it locally instead, in the Console tab of the same developer tools:

const key = 'PASTE_THE_KEY_HERE';
JSON.parse(atob(key.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')));

If the result says "role": "anon", that is the public key and it is supposed to be there. If it says "role": "service_role", you have a problem.

"But the new secret keys don't work in a browser"

That is true, and it is easy to take the wrong comfort from it. Supabase's new secret keys are rejected when a request comes from a browser, which Supabase detects from the request's User-Agent header. That protects you from accidentally using the key in your own frontend. It does nothing against someone who copies the key out of your page and uses it from a script, where the User-Agent is whatever they want it to be. A secret key in your bundle is a full breach, even if your own app throws an error when it tries to use it.

If you find one

  1. Treat it as compromised. You cannot know who has already copied it.
  2. Replace it. With the new keys, create a second secret key in Settings > API Keys, move your server code to it, then delete the leaked one. A project can hold several secret keys at once, so there is no downtime. If you are still on the legacy service_role key, Supabase now points you towards migrating to the new keys and switching the legacy ones off, rather than rotating them.
  3. Move whatever needed it onto a server. A Supabase Edge Function, or any backend you control. If a feature only works with the secret key, that usually means it needs a proper policy instead.
  4. Go through your logs for requests you don't recognise from around the time the key went live.
  5. Fix the variable name so the next deploy doesn't put it straight back.

Finding a leaked key takes five minutes. Working out what was exposed while it was public, and whether anyone used it, takes longer. That second part is where an outside review earns its keep.