Is the Supabase Anon Key Safe? Yes, If Every Table Has RLS
Is the Supabase anon key safe to put in your frontend? Yes. It is designed to be public, and anyone who opens your site can read it. What it can reach is decided entirely by Row Level Security (RLS) and table grants in your database, so the real question is whether every table it can see is locked down.
Is the Supabase anon key safe to make public?
Yes. The anon key, and the publishable key that replaces it, only identifies your app. Requests made with it run as the anon Postgres role, or as authenticated once a user signs in. With RLS on and tight policies, the key reaches nothing it shouldn't. If one table is open, anyone holding the key can read it.
That answers "can the Supabase anon key be public", "should the Supabase anon key be public" and "can the Supabase anon key be exposed" in one go: it will be exposed, because it ships inside your JavaScript bundle or your mobile app. Supabase's API keys guide lists the publishable key, the anon key's replacement, as safe to expose in web pages, mobile and desktop apps, CLIs and even source code. Hiding it is not the fix. Locking down the tables is.
What the anon key actually is
The key tells Supabase what is calling: a web page, a mobile app, a server. It does not say who. Supabase Auth handles who, with a separate access token for each signed-in user.
Every request resolves to a built-in Postgres role, and that role is what your RLS policies match on:
| Key used | User signed in? | Postgres role |
|---|---|---|
| Anon or publishable key | No | anon |
| Anon or publishable key | Yes | authenticated |
| Service role or secret key | Not applicable | service_role |
So a visitor who never signs in gets anon. A signed-in user gets authenticated, even though your app still sends the same public key. And service_role skips RLS entirely, which is why that key must never leave your server.
Is the Supabase anon key sensitive?
The key itself is not a secret. Your data is. Supabase's docs are explicit that a public key does not protect you against someone reading your bundle, watching the browser's Network tab, or reverse-engineering your app. Treat it the way you treat your site's URL: public, and only as safe as what sits behind it.
What a stranger can do if one table is open
This is the part worth understanding concretely, because it turns "RLS matters" into something you can test. Your frontend code already names your tables, in lines like supabase.from('profiles'). Anyone can read those names in the bundle. With your project URL and the public key, they can call the Data API directly, without your UI.
Run this against your own project to see what they would see. Replace the project ref, the table name and the key:
curl 'https://<PROJECT_REF>.supabase.co/rest/v1/profiles?select=*&limit=5' \
-H "apikey: <PUBLISHABLE_OR_ANON_KEY>"There is no user token in that request, so it runs as anon. What comes back depends on the table:
| Table state | What the request returns |
|---|---|
RLS off, and anon holds a grant | Every row. Writes work too, if the grant includes them |
RLS on, a to anon using (true) policy | Every row the grant allows |
RLS on, no policy for anon | An empty list, [] |
No grant for anon | A 42501 permission denied error |
The first row is the dangerous one. Supabase's RLS guide puts it plainly: a table in an exposed schema without RLS is readable and writable by any role with a grant on it. On existing projects, a new table in public gets select, insert, update and delete granted to anon and authenticated by default. Tables created through the Dashboard have RLS on by default. Tables made in the SQL Editor, or by a migration your AI assistant wrote, need enable row level security explicitly.
The same table with writes open means a stranger can POST, PATCH or DELETE through the same endpoint. You don't need to test that on production data: the read test plus the grant check below tells you whether it is possible.
Supabase anon key and RLS: the using (true) trap
A policy can be on and still open everything. using (true) means "every row passes". The docs note that to anon using (true) gives every signed-out visitor read access to every row the role can already reach, and that it is only for data meant to be public, like a list of published blog posts.
to authenticated using (true) looks stricter, but it usually isn't. If your app lets anyone sign up, anyone can become authenticated in a few seconds. Anonymous sign-ins in Supabase Auth also use the authenticated role. A policy like that protects a table of every user's data from nobody who bothers to make an account.
For policies that actually restrict rows, see Supabase RLS policy examples.
Supabase anon key vs publishable key
They do the same job. The publishable key (sb_publishable_...) is the new format, and the docs describe the anon key as its legacy version. Both carry the same low privileges and map to the same roles, so your RLS policies behave exactly the same with either one.
Is the Supabase anon key the same as the publishable key?
Not the same value, but the same role. The differences:
- Format. The anon key is a long-lived JWT that starts with
eyJ. The publishable key is a short string that starts withsb_publishable_. If a tutorial or an AI assistant tells you to copy a longeyJ...key, it was written for the legacy keys. - Header. Send publishable and secret keys on the
apikeyheader, not onAuthorization: Bearer. They aren't JWTs, so anything that tries to verify them as one fails. - Status. Supabase says it is deprecating the
anonandservice_rolekeys by the end of 2026.
Both systems work at the same time. Creating publishable and secret keys does not revoke your legacy keys. They stay valid until you deactivate them yourself under Settings > API Keys. The migration guide walks through it: create the new keys, swap the publishable key into client code, swap the secret key into backend code, confirm nothing still uses the old ones (including app versions already on people's phones), then deactivate the legacy keys. Deactivation is reversible if you find a client you missed.
Switching keys changes nothing about your exposure. A table that was open with the anon key is just as open with the publishable key.
Supabase anon key vs service key
This is the distinction that matters most:
| Anon or publishable key | Service role or secret key | |
|---|---|---|
| Postgres role | anon or authenticated | service_role |
| RLS applies | Yes | No, it bypasses every policy |
| Safe in a browser or app | Yes | Never |
| Where it belongs | Anything you ship | Servers, Edge Functions, cron jobs |
The service role key, and the secret key (sb_secret_...) that replaces it, is the master key. If it ends up in your frontend, RLS stops mattering, because nothing you wrote applies to it. The new secret keys refuse to work from a browser (Supabase matches on the User-Agent header and returns 401), but an attacker can still use a leaked one from any other tool. If you're not sure which key your frontend uses, read the Supabase service role key guide next.
A 5-minute self-check
Do this before you point a domain at the app.
- Confirm which key your frontend ships. Open your
.envand find the variable your Supabase client reads, usually something likeNEXT_PUBLIC_SUPABASE_ANON_KEY,NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEYorVITE_SUPABASE_ANON_KEY. It must be the anon or publishable key. If any public variable holds the service role or secret key, stop and fix that first. - Find tables with RLS off, and what
anoncan do to them. Run this in the Dashboard's SQL Editor:
select
c.relname as table_name,
c.relrowsecurity as rls_enabled,
has_table_privilege('anon', c.oid, 'select') as anon_can_read,
has_table_privilege('anon', c.oid, 'insert,update,delete') as anon_can_write
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public'
and c.relkind in ('r', 'p')
order by rls_enabled, table_name;- Read the result. Any row with
rls_enabled = falseandanon_can_read = trueis readable by anyone with your public key, andanon_can_write = trueon the same row means they can change it too. Fix those first. - Find policies that let every row through. Still in the SQL Editor:
select tablename, policyname, roles, cmd, qual, with_check
from pg_policies
where schemaname = 'public'
and (qual = 'true' or with_check = 'true');- Decide for each result whether it is public on purpose. A product catalog can be. A table of profiles, orders or messages can't.
- Check your views. Views bypass RLS by default, because Postgres creates them as
security definer. On Postgres 15 and above, setsecurity_invoker = trueon any view inpublicthat reads a protected table. - Open the Security Advisor in the Dashboard. Its checks include tables in
publicwith RLS disabled and policies that allow unrestricted access. Review each finding before dismissing it. - Run the curl test from earlier against the tables you just fixed. Signed out, you should get
[]or a permission error, not your users' rows.
If your schema lives in supabase/migrations, a free Tuvio scan flags these patterns in the repo's .sql files: RLS disabled on a table, using (true) or with check (true) policies, policies open to any logged-in user, policies that trust user_metadata, and security definer functions without a search_path. It reads the migration files, not your live database, so a policy you created by hand in the Dashboard won't show up, and a table that is public on purpose gets flagged too. See what a scan checks.
How to fix an open table
Here is a pattern adapted from Supabase's RLS guide, for a table that holds one row per user. Put it in a migration so it ships with your schema:
alter table public.profiles enable row level security;
-- Take back the default grants, then give back only what the app uses.
revoke all on table public.profiles from anon, authenticated;
grant select, update on table public.profiles to authenticated;
create policy "Users can view their own profile."
on public.profiles for select
to authenticated
using ( (select auth.uid()) = user_id );
create policy "Users can update their own profile."
on public.profiles for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );Two details the docs call out. Adding policies doesn't take grants back, so revoke what each role shouldn't have and treat grants and policies as two layers you set together. And grants are checked before policies: if a request that should work fails with 42501, check the grant before you rewrite the policy. The Securing your API guide covers grants, and how to stop new tables from getting them automatically.
For the full walkthrough of a table that was left open, see Supabase RLS disabled in public.
The short version
- The anon key and the publishable key are public by design. Don't spend time hiding them.
- Their safety comes from RLS and grants on every table they can reach, and nothing else.
using (true)is open to everyone, andto authenticated using (true)is open to anyone who can sign up.- The service role and secret keys bypass RLS and belong only on a server.
- Moving from the anon key to the publishable key is worth doing before the end-of-2026 deprecation, but it doesn't close a single open table.