Security basics
Which API keys are safe in your frontend, and which aren't
Your Supabase anon key is supposed to be public. Your service_role key is not, and it ignores every rule you set. Here is how to tell them apart.
In short
- Finding an API key in your app's code is not automatically a problem. Some keys are designed to be there.
- Publishable keys are safe in the browser. Secret keys are not, and one in your frontend is an open till.
- Two checks separate them in under a minute: read the prefix, and on an older Supabase key, decode the role.
If you have built an app with Lovable, Bolt, v0, Cursor or Replit, sooner or later someone will open your site, press F12, and tell you your API key is "exposed". It is an alarming thing to hear about an app you cannot read the code of.
Here is the part that guide after guide gets wrong: some of those keys are supposed to be there. Treating every visible key as a breach means either panicking over nothing or, much worse, learning to ignore the warning, and then ignoring it on the day it actually matters.
Is it bad that my API key is visible?
Usually not. It depends entirely on which key it is.
Every service that talks to a browser hands out two different kinds of credential. One is an address. The other is a set of keys to the building. Both are called "API keys", which is most of the reason this is confusing.
An address is safe to publish. It identifies which project a request belongs to and nothing more; the actual permission check happens somewhere else. A key to the building is not safe to publish, because it is the permission check: whoever holds it can do whatever it allows, from anywhere.
Your app needs the address in the browser to work at all. It should never need the keys to the building.
Why your app ships keys to the browser at all
Because the request comes from your visitor's browser, not from your server.
When your app loads a list of your users' orders, that request goes out from the visitor's browser directly to your database provider. It has to say which project it belongs to, and that identifier has to be in the page, because the page is where the request is made from.
There is no version of this where the identifier is secret. It travels to every visitor by design. Which is exactly why providers split their credentials in two: they know one of them is going to be public, so they made that one harmless.
The security does not come from hiding the address. It comes from the rules you set on the other end. In Supabase's case that is Row Level Security, which decides row by row who is allowed to see what. Those rules are the thing worth checking, and switching them on is not the same as being protected.
The two families of key
| Provider | Key | Safe in the browser? | What it does |
|---|---|---|---|
| Supabase | sb_publishable_…, or anon in older projects | Belongs here | Says which project this is. Every request is still filtered by your Row Level Security rules. |
| Supabase | sb_secret_…, or service_role in older projects | Never | Bypasses Row Level Security entirely. Reads and writes every row in every table, whatever your rules say. |
| Stripe | pk_live_… (publishable) | Belongs here | Creates payment forms. Cannot move money or read customers. |
| Stripe | sk_live_… (secret) | Never | Full account access: charges, refunds, payouts, customer records. |
| OpenAI / Anthropic | any API key | Never | There is no publishable variant. Every key bills your account directly. |
The pattern holds beyond these three. If a provider offers exactly one kind of key, assume it is secret and it belongs on a server.
How to tell them apart in 60 seconds
For Stripe, and most providers, read the prefix. pk_ is publishable and
safe. sk_ is secret and is not. Some providers use _test_ and _live_ in
the middle. A leaked test key is a much smaller problem than a leaked live one,
but rotate both.
For Supabase, it depends how old your project is. Supabase has issued two different shapes of key, and both are out there in working apps today.
Newer projects: read the prefix, exactly like Stripe. sb_publishable_… is
the one that belongs in your app. sb_secret_… is the one to rotate today.
There is nothing to decode. What the key is for is written on the front of it.
What changed, and what it means for your app, if
you have not met these yet.
Older projects: you have to look inside the key. The original pair, anon
and service_role, are JWTs: three chunks of gibberish separated by dots, and
the middle chunk is readable data, not encryption. It states the key's role in
plain text.
You do not need a tool for this either way. Open Supabase's own dashboard under Settings → API Keys and it tells you which is which. If you would rather check the key you actually found in your app, the middle section of a legacy key decodes to something like this:
{
"iss": "supabase",
"ref": "abcdefghij…",
"role": "anon", ← this is the one that matters
"iat": 1750000000
}
"role": "anon" is the safe one. "role": "service_role" is the one to rotate
today. In a legacy key that single word is the whole difference, and it is why a
scanner that just matches key-shaped strings gives you noise instead of an
answer. It cannot tell you which of two identical-looking keys you actually
shipped.
If you would rather not go through your app key by key, our free scan reads your live site and tells you which of these it can see from outside. It takes about 20 seconds and needs no account: scan your app.
What actually happens when a secret key leaks
Every row in your database becomes readable and writable by whoever found the
key, including tables you never exposed to your app and your users' personal
data. Row Level Security does not apply to a Supabase secret key (sb_secret_…,
or service_role in an older project). That is the point of the key.
The damage is not theoretical either. It usually surfaces as a support message about data that changed on its own, or as a table that is suddenly empty.
A leaked Stripe sk_live_ key means refunds, charges and customer records. A
leaked model-provider key means a bill, sometimes a very large one, arriving
before anyone notices.
None of this requires an attacker to be sophisticated. Automated scanners crawl public sites looking for exactly these strings, and they do not need to know who you are to find yours.
If you want the platform-specific version of what to check, we have a plain language walkthrough for Supabase apps.
What to do right now
What to do
- Find every key in your app and identify each one. Prefix for Stripe and for newer Supabase keys; the
rolefield inside older Supabase ones. - If you find a secret key in your frontend, rotate it first. Deleting it from your code does not close the door; the old value still exists in your version history and in anyone's cached copy of your site.
- Move whatever needed that key onto a server: an edge function, a serverless route, anything that is not the browser.
- Turn on Row Level Security for every table, then check it actually works. A publishable key (
sb_publishable_…oranon) is only safe because of those rules; without them it reads your whole database. - Check billing and logs after any secret-key exposure. Rotation stops what happens next, not what already happened.
If you would rather work through this as a list, the 10-minute security checklist covers this and the other things worth switching off in a newly launched app.
And if a leak ever does empty a table, whether you get it back depends entirely on what you were backing up.
FAQ
Someone told me my API key is exposed. Should I panic?
Not until you know which key it is. If it starts with pk_ or it is a Supabase anon key, it is meant to be public and nothing is wrong. If it starts with sk_ or it is a service_role key, rotate it now, then look at what it could reach.
Can I just hide the key so nobody finds it?
No. Anything your browser can use, a visitor can read. Minifying, renaming or obfuscating only slows someone down for a few seconds. The fix is never to hide a secret key in the frontend; it is to move it to a server, or use a publishable key that was safe to expose in the first place.
Why does Supabase give me a key that anyone can read?
Because the anon key is not what protects your data. It only says which project you are talking to. The actual protection is Row Level Security, which decides row by row what each visitor is allowed to see. That is why an anon key with RLS switched on is fine, and the same key with RLS switched off is an open database.
My Supabase key starts with sb_publishable_. Is that the same as the anon key?
It does the same job. Supabase gave its keys new names: sb_publishable_ took over from anon, and sb_secret_ took over from service_role. So a key beginning sb_publishable_ is meant to be in your app, and one beginning sb_secret_ never is. Older projects still carry the original anon and service_role keys, and those still work. You may even see both pairs side by side in your dashboard.
I already pasted a secret key into my app. What now?
Rotate it first: in the provider dashboard, generate a new key and revoke the old one. Rotating is what actually closes the door; removing it from your code does not, because the old version is still in your history and in anyone's cached copy. Then move the work that needed it to a server, and check your billing and logs for anything you did not do.