Someone opened your app's dev tools, poked around the JavaScript bundle, and found a long string starting with eyJ. Now they're telling you your Supabase key is exposed and your app is compromised.
Maybe. Maybe not. It depends entirely on one thing, and it's not the key.
The anon key is supposed to be public
Let's get this out of the way first. The Supabase anon key ships in your client bundle by design. That's why the env var is literally named NEXT_PUBLIC_SUPABASE_ANON_KEY. The NEXT_PUBLIC_ prefix in Next.js means "inline this into the JavaScript we send to every browser." Vite does the same thing with VITE_ prefixes. This isn't a leak. It's the architecture.
The anon key does two things. It identifies which Supabase project a request belongs to, and it maps the request to the anon Postgres role. That's it. It's an identifier with a role attached, not a password. Supabase's own docs tell you to put it in client code, and their newer key format makes the intent explicit: the replacement for the anon JWT is literally called a publishable key, prefixed sb_publishable_. Publishable. As in, publish it.
So when a scanner or a well-meaning stranger flags your anon key as "exposed," that alone tells you nothing. Every Supabase app on the internet has an exposed anon key. Mine included.
What the anon key actually grants
Here's where it gets real. Anyone holding your anon key can talk directly to your database's REST API. Not through your app. Not through your UI. Straight to PostgREST:
curl "https://yourproject.supabase.co/rest/v1/users?select=*" \
-H "apikey: YOUR_ANON_KEY" \
-H "Authorization: Bearer YOUR_ANON_KEY"
That request hits your users table as the anon role. Whether it returns an empty array or every row you have depends on Row Level Security. RLS is a Postgres feature where each table carries policies that decide, per row, what a given role can see or change. With RLS enabled and no policy granting anon access, that curl returns nothing. Locked out at the database layer, exactly as designed.
With RLS disabled on that table? Full read. And if there's no policy restricting writes, full insert, update, and delete too. The anon key holder doesn't need your login page. They ARE a database client.
So the honest answer to "is my exposed anon key a problem" is: run that curl against your own tables and find out. Takes about two minutes. If a table you'd consider private returns data, you don't have a key exposure problem. You have an RLS problem wearing a key exposure costume.
Why AI-built apps get this wrong constantly
I review AI-built apps before launch for a living. Lovable, Bolt, Replit, v0, Base44, Cursor output, all of it. I wrote up the patterns from 20 of those reviews, and missing or permissive RLS was the finding I hit over and over. Not exotic vulnerabilities. This one, repeatedly.
The mechanism is almost sympathetic when you watch it happen. The AI builder scaffolds your tables. Supabase enables RLS on new tables by default, which is good, but now every query from the app returns nothing because no policies exist yet. The app looks broken. So either the tool or the founder, following an error message at 11pm, disables RLS or writes a policy like USING (true) that allows everything. The app springs to life. Everything works. Ship it.
Except "works" and "secure" got swapped somewhere in that loop. The app works precisely BECAUSE the database stopped checking who's asking. Every prompt after that reinforces the broken state, since the AI's definition of success is "the feature functions," and it functions great when nothing is enforced. I went deeper on how often this shows up in the wild in the exposure report, and it's a big part of why people keep asking whether specific builders like Base44 are safe. The tools aren't inherently unsafe. The default failure mode is.
The key that actually is a secret
Quick but important detour. Supabase gives you a second key: the service role key, now issued as sb_secret_ in the new format. This one bypasses RLS entirely. Every policy, ignored. It's meant for trusted server environments only.
If THAT key is in your client bundle, stop reading and rotate it now. Supabase dashboard, project settings, API keys, rotate. This happens more than you'd think, usually because someone pasted the wrong key into the wrong env var, or an AI tool "fixed" a permissions error by swapping in the key that made the error go away. A service role key in the browser means RLS doesn't matter. Game over regardless of your policies.
How to tell them apart: the anon key's JWT payload says "role":"anon", the service key says "role":"service_role". Paste the token into any JWT decoder and look. Or check the prefix on the newer keys, sb_publishable_ versus sb_secret_. Thirty seconds of checking versus weeks of cleanup.
What to actually do
Enable RLS on every table in the public schema. No exceptions, even the "harmless" ones, because harmless tables have a way of growing user data later. Then write real policies. USING (auth.uid() = user_id) for user-owned rows, not USING (true). Supabase's dashboard has a security advisor that flags tables with RLS disabled, and it's worth checking after every schema change your AI tool makes, because those tools create tables freely and don't always secure them.
Then test from outside your app. That curl command above, against every table, with only the anon key. Your app's UI hiding data proves nothing. The UI is a suggestion. The database is the enforcement point, and the anon key in every visitor's browser means every visitor can skip the suggestion.
And if you'd rather have experienced eyes on it before launch, that's a thing I do. The Vibe Check Launch Risk Review is a pre-launch pass over exactly this category of problem, RLS included. Not the only way to get there. A capable friend with Postgres experience and that curl command covers a lot of the same ground. But if you don't have one handy, it's an option.
The anon key being public was never the risk. The risk is a database that stopped asking questions.