Security6 min read

Is the Supabase Anon Key Safe to Expose? Only If RLS Says So

The Supabase anon key is meant to be public. Whether publishing it is safe depends entirely on Row Level Security. Here is how to tell, table by table, in two minutes.

By UseShipReady

Yes, the Supabase anon key (now also called the publishable key) is designed to be public: it ships in your front end on purpose. But it is only safe when every table it can reach has Row Level Security enabled with policies that limit what the anonymous role sees. With RLS off, anyone who copies the key from your JavaScript can read, and usually write, the whole table. The key that must never be public is the service role key, now called the secret key.

This question comes up constantly because two true statements seem to contradict each other. Supabase tells you the anon key is fine to publish. Security advice tells you never to publish a database credential. Both are right, and the thing that reconciles them is Row Level Security. Understanding that one relationship is most of Supabase security.

What does the Supabase anon key actually do?

The anon key identifies your project and tells Supabase to treat the request as the anon Postgres role. It does not grant any particular data by itself. What the anon role may read or change is decided by Postgres privileges and, above all, by the RLS policies on each table. Think of the key as a visitor badge: it gets someone into the lobby, and the rules on each door decide the rest.

Newer Supabase projects issue a publishable key (sb_publishable_...) and a secret key (sb_secret_...) in place of the older JWT-style anon and service_role keys. The model is the same: the publishable key is for browsers, and the secret key bypasses RLS entirely and belongs only on a server.

KeySafe in the browser?Respects RLS?
anon / publishable (sb_publishable_)Yes, by designYes
service_role / secret (sb_secret_)NeverNo, it bypasses RLS
Which Supabase key can go where

When is the anon key not safe?

Three situations turn a public anon key into a public database.

1. RLS is disabled on a table

Tables in the public schema are exposed through Supabase’s Data API, and by default the anon role holds privileges on them. If RLS is off, those privileges are the only gate, so the table is readable and writable by anyone with the key. Tables created in the dashboard’s Table Editor get RLS switched on by default, but tables created through raw SQL or a migration file an AI assistant wrote often do not. This is exactly the pattern documented in CVE-2025-48757, where apps generated with Lovable shipped Supabase tables anyone could read.

2. RLS is on, but a policy says using (true)

A policy of using (true) for the anon role means "every row, for everyone". It is the policy an assistant writes when asked to "make the error go away", because it does make the error go away. RLS shows as enabled in the dashboard, which is what makes this version easy to miss.

3. The policy trusts any signed-in user

A policy scoped to authenticated using (true) looks stricter, but it only requires an account. If sign-up is open, anyone can create one in seconds, so the data is public with one extra step. It gets worse when email verification is disabled or anonymous sign-ins are enabled: then an account costs nothing at all.

How do I check what my anon key can read?

Use the key exactly as a stranger would. Copy your project URL and anon key from your site’s JavaScript (they are in the bundle; that is the point) and ask for a row count, not the rows themselves:

shell
curl -sI "https://YOUR-PROJECT.supabase.co/rest/v1/orders?select=*" \
  -H "apikey: YOUR_ANON_KEY" \
  -H "Authorization: Bearer YOUR_ANON_KEY" \
  -H "Prefer: count=exact" | grep -i content-range

The number after the slash in Content-Range is how many rows the anonymous role can see. */0 means none are visible, which is what correct RLS looks like for private data (and also what an empty table looks like, so check one you know has rows). Anything above zero on a table holding user data is the problem this article is about. Repeat for each table your front end queries: search your code for .from(' to list them.

The Supabase dashboard also has a Security Advisor that lists tables in the public schema with RLS disabled. Run it, but do not stop there: it will not tell you that an enabled policy says using (true).

How do I fix a table the anon key can read?

  1. Enable RLS on every table in the public schema: alter table public.orders enable row level security;. With RLS on and no policies, the table denies everything, which is a safe place to start.
  2. Add the narrowest policy that makes your app work. For per-user data that means matching the owner column to auth.uid().
  3. Delete any using (true) policy on a table holding personal or business data, unless the data is genuinely meant to be public (a product catalogue, published blog posts).
  4. Re-run the count query above with the anon key and confirm the number dropped.
  5. Check your storage buckets too. A public Supabase Storage bucket serves every file to anyone who can guess or list a path.
sql
alter table public.orders enable row level security;

create policy "Users read their own orders"
  on public.orders for select
  to authenticated
  using ((select auth.uid()) = user_id);

create policy "Users create their own orders"
  on public.orders for insert
  to authenticated
  with check ((select auth.uid()) = user_id);

Wrapping auth.uid() in a select is the form Supabase recommends for performance: Postgres evaluates it once per query rather than once per row.

What if the service role key is in my front end?

Then RLS does not matter, because that key ignores it. Treat it as an incident: rotate the key in the Supabase dashboard immediately, move every call that needed it into a server route or Edge Function, and redeploy. Then work out how it got into the bundle. The usual cause is an environment variable given a browser-exposed prefix like NEXT_PUBLIC_ or VITE_, which leaks API keys into your JavaScript bundle the same way for every provider. UseShipReady reports an exposed Supabase secret key as critical.

How UseShipReady checks this

UseShipReady reads your project URL and anon key from your own JavaScript, discovers the tables and functions your app actually calls, and asks each one for a row count with the anon key, the same HEAD request shown above. No row is ever retrieved. A table that reports readable rows is reported as a table readable without Row Level Security, with the table name and count as evidence, so you can reproduce it yourself.

For the full list of Supabase settings worth reviewing before launch, see the Supabase security checklist. If your app uses Firebase instead, the same idea applies with different syntax: see how to check Firebase Realtime Database rules.

Frequently asked questions

Is it safe to put the Supabase anon key in my frontend code?
Yes, provided Row Level Security is enabled on every table the key can reach and your policies restrict what the anonymous role sees. The key is designed to be public; RLS is what makes publishing it safe.
Should I hide the anon key in an environment variable?
You can, but it does not hide anything. Any value your browser code uses ends up in the JavaScript bundle, readable by anyone. Environment variables keep keys out of your repository, not out of the browser.
What is the difference between the anon key and the publishable key?
They do the same job. The publishable key (sb_publishable_) is the newer format Supabase introduced to replace the JWT-based anon key, alongside a secret key (sb_secret_) that replaces the service role key.
Does enabling RLS break my app?
Briefly, until you add policies. RLS with no policies denies every request, so queries return nothing. Add one policy per operation your app needs, test, and ship. That short breakage is much better than a public table.

Sources

Related reading

Is your site ready to ship?

UseShipReady scans up to ten pages for security, AI exposure, email deliverability, SEO and launch readiness — with a paste-ready fix for each finding. Free, no signup.