HeHeedza

Is it safe that my Supabase anon key is visible in the browser?

Last reviewed

Short answer

Yes, the anon key and the newer sb_publishable_ key are designed to be public. But they're only safe if Row Level Security is on for every table your app exposes, with policies that restrict each user to their own rows for select, insert, update and delete. If RLS is off or too loose, anyone can use your public key to read or change your data.

Why this happens

Supabase's docs describe the publishable key as "taped to the front door": it only opens what RLS allows. With the key, anyone can call your project's REST API directly, bypassing your UI entirely. So your front-end checks ("only show the admin button to admins") protect nothing. The database policies are the real lock.

This went wrong at scale with AI-built apps. In CVE-2025-48757, researchers scanned the homepages of 1,645 Lovable showcase projects and found 170 with inadequate RLS. Exposed data included user tables, subscriptions and stored API keys, and in one case they could write payment_status: "paid" without paying.

Common gaps:

  • RLS disabled on a table created by a migration.
  • using (true) select policies on private data.
  • Policies only for SELECT. Missing insert, update or delete policies, or with check (true).
  • "Any logged-in user" policies (to authenticated using (true)) on per-user data.
  • Views or security definer functions that skip RLS.

How to fix it, step by step

1. Find tables without RLS:

select schemaname, tablename, rowsecurity
from pg_tables
where schemaname = 'public'
order by rowsecurity, tablename;

Every row should say true. Enable it where it doesn't: alter table public.<table> enable row level security;

2. Review every policy:

select tablename, policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname = 'public'
order by tablename, cmd;

Look for qual = true, with_check = true, policies for anon on private tables, and tables missing UPDATE or DELETE rules.

3. Use owner-scoped policies (assuming a user_id column):

create policy "own rows: select" on public.notes for select to authenticated
  using ((select auth.uid()) = user_id);
create policy "own rows: insert" on public.notes for insert to authenticated
  with check ((select auth.uid()) = user_id);
create policy "own rows: update" on public.notes for update to authenticated
  using ((select auth.uid()) = user_id) with check ((select auth.uid()) = user_id);
create policy "own rows: delete" on public.notes for delete to authenticated
  using ((select auth.uid()) = user_id);

4. Never let users set sensitive fields. Columns like role, plan or payment_status should only be written by server code (webhooks, Edge Functions with the secret key), not through an update policy users can use.

5. Check views and functions. Create views with with (security_invoker = true) (Postgres 15+), and review any security definer function exposed through RPC.

6. Test as an outsider. With only the public key, try reading a private table:

curl "https://<ref>.supabase.co/rest/v1/notes?select=*&limit=1" \
  -H "apikey: <publishable-or-anon-key>"

You should get [] or a permission error, never someone's data. Then run Advisors → Security Advisor in the dashboard.

Fix prompt for your AI builder

Paste this into Lovable, Bolt, Cursor, Claude, ChatGPT, Replit, v0 or whichever tool you build with. Replace the bracketed parts first.

Fix prompt
Audit our Supabase Row Level Security. Our public key is in the browser, so RLS is the only thing protecting data. Please:
1. List every table in the public schema and say whether RLS is enabled. Write a migration enabling it on any table where it's off.
2. For each table, write policies for select, insert, update and delete for the authenticated role, scoped to (select auth.uid()) = user_id (or the right owner column). Remove any using (true) or with check (true) policies on private data.
3. Make sure columns like role, plan, is_admin and payment_status can't be updated by users. Only server code may change them.
4. Flag any views without security_invoker and any security definer RPC functions.
Don't disable RLS anywhere and don't move the secret or service_role key into the front end.

How to catch this next time

RLS regresses quietly. A new table created by an AI edit starts without the policies you carefully wrote for the others.

Heedza's passive security scan reads your homepage, its inline scripts and up to six same-origin JavaScript bundles. It tells you when Supabase config and a public key (legacy anon or new sb_publishable_) are visible, which is expected. It raises a high alert if a service_role key is there and a critical one for an sb_secret_ key. If you verify you own the domain (DNS TXT record or meta tag), Heedza also makes single reads with that key against up to three tables listed in your API schema and flags any that return rows. It doesn't test inserts, updates or deletes, so the SQL review above still matters. Start with the free scan of your live URL.

Questions

Should I hide or rotate my anon key?

You can't hide it. Any browser app has to ship it. Rotating it doesn't help if RLS is open, because the new key works just the same. Fix the policies.

What's the difference between the anon key and sb_publishable_?

The publishable key is the newer replacement for the legacy anon JWT. Both map to the anon role and both depend on RLS. Supabase plans to deprecate the legacy keys by the end of 2026.

Does Lovable's security scan cover this?

Use any built-in scan as a starting point, but review the policies yourself. Checking that a policy exists isn't the same as checking it restricts the right rows.

Is the service_role key ever OK in the browser?

No. It bypasses RLS completely. See our guide on what to do if it's exposed.

Sources