You pushed your app, opened DevTools, and there it is in plain sight: your Supabase key, sitting in the JavaScript bundle for the whole world to read. Your stomach drops. Did you just leak the keys to your database?
Take a breath. In the overwhelming majority of cases, the answer is: no, that key is public by design and you did nothing wrong. But there's a real version of this panic that is an emergency, and the difference between the two is worth understanding precisely. Let's sort it out calmly.
Every Supabase project has two keys
When you spin up a project, Supabase hands you two API keys, and they could not be more different:
-
anon / publishable key (
role: anon) — the public one. It is meant to ship in your browser bundle, your mobile app, your public repo. It is not a secret. It does not need rotating just because someone saw it. -
service_role key (
role: service_role) — a server-only secret. It bypasses Row Level Security entirely — full read, write, and delete on every table, plus Storage. This one must never touch a browser, a public repo, or client config.
Newer projects use the sb_publishable_... and sb_secret_... formats, where the prefix alone tells you which is which. Older projects use JWTs that both start with eyJ... and look nearly identical at a glance — which is exactly why people panic.
So the first question isn't "is my key exposed?" It's "which key is exposed?"
Decode the key to find out which one you have
Both legacy keys are JWTs: three base64url segments separated by dots. The middle segment is the payload, and it names the role. You never need a library — just decode the middle part.
In the browser console:
const key = "eyJhbGciOi..."; // paste your key
JSON.parse(atob(key.split(".")[1]));
// { "role": "anon", "iss": "supabase", ... } -> public, safe to ship
// { "role": "service_role", ... } -> secret, get it out now
Or from a terminal:
echo "eyJhbGciOi..." | cut -d. -f2 | base64 -d 2>/dev/null
If you see "role":"anon" — relax. That key in your bundle is doing exactly what it's supposed to.
If you see "role":"service_role" — this is the real emergency. Skip to the last section.
Why is the anon key safe to make public?
Because the anon key isn't what protects your data. Row Level Security (RLS) is.
Think of the anon key as a library card that only says "this person is an anonymous visitor." It gets you through the front door of the API. What you can actually read or touch once inside is decided by RLS policies on each table. With RLS enabled and no policy granting access, the anon role sees nothing.
This is the mental-model shift that dissolves the panic: exposing the anon key is only a problem if your tables aren't protected by RLS. The key was never the wall. It's the doorbell.
The actual thing to check: is RLS on and scoped?
Here's where the genuine risk lives, and it has nothing to do with the key being visible. Run this in the Supabase SQL editor to find every table in public with RLS switched off:
select relname as table_name, relrowsecurity as rls_enabled
from pg_class
where relnamespace = 'public'::regnamespace
and relkind = 'r'
order by relrowsecurity, relname;
Any row where rls_enabled is false is readable and writable by anyone holding your anon key — which, again, is everyone. That's the leak that matters.
Enabling RLS is one line per table:
alter table public.profiles enable row level security;
But RLS with no policies means nobody except service_role can read anything — so you then add policies that scope access. A quick model of how policies work, because the details trip people up:
-
USINGfilters which existing rows a role may see, and which it mayUPDATE/DELETE. -
WITH CHECKvalidates rows being inserted or updated.INSERTpolicies haveWITH CHECKonly. - A role needs both a matching policy and the table
GRANT. Permissive policies OR together; restrictive ones AND. - A policy with no
TOclause applies to every role, includinganon. Classic footgun. - For the anon role,
auth.uid()isNULL— soauth.uid() = user_idnaturally matches nothing for anonymous callers.
A typical "users only see their own rows" policy:
create policy "own rows are visible"
on public.profiles
for select
to authenticated
using ( auth.uid() = user_id );
Now audit what you actually have. This lists every policy and its expressions so you can eyeball the dangerous ones:
select tablename, policyname, cmd, roles, qual as using_expr, with_check
from pg_policies
where schemaname = 'public'
order by tablename, cmd;
Two patterns to hunt for:
-
using (true)on aSELECTpolicy that reaches anon = world-readable table. Sometimes intentional (a public blog); often not. -
using (true)on anINSERT,UPDATE, orALLpolicy = world-writable. That's how strangers end up inserting rows into your database.
If reading raw pg_policies output makes your eyes cross, I put together a small public repo that reproduces the exact leaky-vs-safe setup on a throwaway project, with the read-only audit SQL ready to paste: github.com/cekuu35/supabase-rls-leak-demo. Clone it, run the queries against your own project, and you'll see immediately which tables are exposed.
The NEXT_PUBLIC_ question
Frameworks inline any env var prefixed NEXT_PUBLIC_ (Next.js), VITE_ (Vite), or EXPO_PUBLIC_ (Expo) directly into the client bundle. That's correct for the anon key and project URL — they're meant to be public:
NEXT_PUBLIC_SUPABASE_URL=https://xxxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGci... # fine, this is public
The catastrophe is putting the service_role key behind a public prefix. NEXT_PUBLIC_SERVICE_ROLE_KEY ships an RLS-bypassing master key to every visitor. Service_role belongs only in server code — API routes, Edge Functions, backend jobs — under an unprefixed name.
If you exposed the service_role key
This is the one case that's a genuine incident. Do this now:
- Rotate it. Supabase Dashboard -> Project Settings -> API Keys -> roll the service_role key. Every copy of the old one stops working immediately.
-
Purge it from git history. Deleting the file isn't enough — a committed secret lives in history forever. Use
git filter-repoor BFG:
git filter-repo --invert-paths --path .env
# or: bfg --delete-files .env && git reflog expire --expire=now --all && git gc --prune=now
- Force-push, then rotate anything else that shared that file.
The takeaway
- Anon key in your bundle? Expected. Safe. Don't rotate it.
- The real question is whether RLS is on and correctly scoped on every table.
- Service_role key anywhere public? Rotate and purge history immediately.
Decode your key, run the two audit queries above, and you'll know exactly where you stand in about five minutes.
If you'd rather not hand-audit every policy — especially on a project with dozens of tables where one stray using (true) is easy to miss — I've packaged the full policy checklist, annotated audit queries, and a scoring rubric into a $29 RLS Audit Kit. Totally optional; the free demo repo above gets most people what they need. Either way, don't let a screenshot of your anon key ruin your afternoon. Check RLS instead.
Top comments (0)