Skip to main content
ALL GUIDES
14 min readUpdated

Your Supabase Anon Key Is Meant To Be Public

The Supabase anon (publishable) key belongs in your frontend — it is not a leak. Row Level Security is the only thing between it and your data, and RLS fails silently in about six documented ways. Checklist and audit prompt inside.

Short answer: the Supabase anon key, now called the publishable key, is meant to be exposed in your frontend. It is not a leak. The only thing standing between that public key and your data is Row Level Security. If RLS is turned off, or it isn't configured correctly, anyone who pulls that key out of your JavaScript can reach your database tables and read information they were never supposed to see.

Before any of this, do one thing: ask your AI to list every table in your database and explain what each one is for. You need to know which tables should be publicly readable and which should never be, because only you know what your app actually does. Once you understand that, you can direct the AI properly instead of handing it the whole problem and hoping.

Below is the checklist and an audit prompt you can paste into your AI. The explanation of why each item matters follows underneath.

Every claim here is sourced from Supabase's own documentation, linked at the bottom.

The checklist

Understand your own database first

  • You can list every table and say what it is for
  • You know which tables should be readable by anyone, and which should never be
  • You decided that, not the AI

Tables

  • RLS enabled on every table in an exposed schema, including every table created in raw SQL or by a migration
  • Every table has policies, not just RLS switched on
  • No policy uses USING (true) or 1=1
  • No FOR ALL policies, four separate ones instead
  • Every UPDATE policy has both USING and WITH CHECK
  • Every UPDATE policy has a matching SELECT policy
  • Policies handle the null case explicitly: auth.uid() is not null and ...
  • No policy reads user_metadata, which the user can write to
  • Role-based access reads from a table you control, or a server-set custom claim
  • The tenant or org id comes from the session or a server-set claim, never from request input

Performance

  • Function calls wrapped: (select auth.uid())
  • Indexes on columns used in policies
  • to authenticated specified on every policy
  • Joins written user-first, not row-first

Beyond tables

  • Views created with security_invoker = on
  • No materialized views or foreign tables carrying data that RLS should protect
  • SECURITY DEFINER functions have search_path = '' and fully qualified table names
  • Storage buckets have policies on storage.objects
  • Public buckets have no broad listing policy
  • Realtime: "Allow public access" disabled if you rely on private channels
  • Realtime: you have thought about DELETE payloads, which RLS does not filter
  • Edge Functions authorize the caller in code if verify_jwt is off

Keys

  • No secret or service_role key anywhere in frontend code, the repo, or the built bundle
  • You know which of your keys is publishable and which is secret
  • Any key that has ever leaked was rotated, and for signing keys, explicitly revoked
  • Grants reviewed ahead of the 30 October 2026 Data API change

Verification

  • Security Advisor run, ERROR-level findings cleared
  • App opened logged out, tried to read data
  • App opened as a second user, tried to read the first user's data

The audit prompt

Paste this into your AI along with your schema and policies. It is written to make the model report gaps rather than reassure you.

text
Audit the Supabase security configuration in this project. Your job is to find
what is missing or wrong, not to tell me it looks fine.

For every item below answer PRESENT (and show the code), MISSING, or INCORRECT
(and explain what an attacker could do). If you cannot find the relevant code,
say MISSING. Do not assume Supabase handles something unless you can point at
the configuration proving it. At the end, rank findings by exploitability and
give me the fix for the top five.

START HERE
1. List every table in the database and explain in one line what each table
   appears to be for, based on its columns and how the app uses it. Then tell
   me which ones look like they hold data that should be private to one user
   or one organisation. I will confirm before you continue.

TABLES AND POLICIES
2. List every table in an exposed schema and whether RLS is enabled on each.
3. List every table that has RLS enabled but no policies, and every table that
   has policies but does not have RLS enabled.
4. Find any policy using USING (true), 1=1, or no meaningful condition.
5. Find any FOR ALL policies.
6. For every UPDATE policy: does it have both USING and WITH CHECK? If it only
   has USING, explain that a user can reassign ownership of their own row.
7. For every UPDATE policy: is there a matching SELECT policy?
8. Find any policy that would behave unexpectedly when auth.uid() is null.
9. Find any policy that reads user_metadata. That data is user-writable and
   this is an account escalation path.
10. Where does role or tenant information come from? If it comes from request
   input or user_metadata rather than the session or a server-set claim,
   flag it.

BEYOND TABLES
11. List every view. Which are created with security_invoker = on? Any view
    without it may bypass RLS on its underlying tables.
12. Any materialized views or foreign tables exposed to the API?
13. Any SECURITY DEFINER functions? Do they set search_path and fully qualify
    table names? Can anon or authenticated execute them?
14. Storage: are there policies on storage.objects? Any public bucket with a
    broad listing policy?
15. Realtime: is private mode relied on? Has public access been disabled? Are
    DELETE payloads a concern given RLS does not filter them?
16. Edge Functions: is verify_jwt disabled anywhere, and if so does the handler
    authorize the caller itself?

KEYS
17. Search the entire codebase, including the built bundle and committed env
    files, for a service_role or sb_secret_ key. Report any hit as critical.
18. Is any key sent in an Authorization Bearer header that should be sent in
    the apikey header?

PERFORMANCE
19. Any policy calling auth.uid() without wrapping it as (select auth.uid())?
20. Any policy without a TO clause?
21. Are the columns used in policies indexed?

Now audit and report.

Why each of these matters

The rest of this is the reasoning behind the checklist. Read it if a line above did not make sense, or if you want to know how these fail in practice.

The two keys

Supabase gives you two kinds of key and the difference is the whole ballgame.

Publishable, formerly anon. Safe in the browser. Every request it makes is subject to RLS. Supabase's framing is useful: the publishable key identifies what is accessing the project, while Auth identifies who.

Secret, formerly service_role. Runs as a Postgres role carrying the BYPASSRLS attribute. It skips every policy you have ever written. Supabase's instruction is blunt: never use it in a browser, even on localhost.

Supabase now actively defends this. A secret key used from a browser gets HTTP 401 back. That guardrail only exists on the new sb_secret_ format, and it exists because people kept doing it.

The trap

The sequence goes like this, and it is almost universal.

You enable RLS. Your query starts returning an empty array. Nothing errors, the request succeeds, the data is simply gone. You are on a deadline. Somewhere you have a service key that makes it work again, so you swap it in.

It works. Of course it works. It works because the service key ignores the policy you just wrote.

Read what actually happened. The empty array was the system functioning correctly. RLS with no policies denies everything by default, which is exactly what it should do. You did not fix a bug. You disabled the control and shipped the result.

Supabase's own linter has a name for the other version of this shortcut. Rule 0024 flags policies written as USING (true), and their description of where those come from is unusually honest: "often from copy-paste errors or forgotten development placeholders."

RLS is on by default, except when it isn't

Create a table in the dashboard Table Editor and RLS is enabled for you.

Create the same table in the SQL editor, or in a migration, or by asking an AI to write the schema, and it is not. Supabase's docs say it plainly: if you create one in raw SQL, remember to enable RLS yourself.

Which path do you think AI-generated projects take?

sql
alter table public.your_table enable row level security;

Four ways policies fail silently

These are ordered by how often they slip through.

1. The null comparison

auth.uid() returns null for an anonymous request. In SQL, null = user_id evaluates to null, which is not true, so the row is filtered. That sounds correct, and in the SELECT case it usually is. The problem is that nothing anywhere errors or warns, so a policy that is subtly wrong looks identical to one that is right.

Be explicit instead of relying on null semantics:

sql
using ( auth.uid() is not null and (select auth.uid()) = user_id )

2. UPDATE without WITH CHECK

USING decides which existing rows you may touch. WITH CHECK decides what the row is allowed to look like afterwards. Supabase's rules per command:

  • SELECT: USING, no WITH CHECK
  • INSERT: WITH CHECK, no USING
  • UPDATE: both
  • DELETE: USING, no WITH CHECK

Now the trap. From the Postgres behaviour Supabase documents: if no WITH CHECK expression is defined, the USING expression is used both to determine which rows are visible and which new rows are allowed.

So write an UPDATE policy with only USING (auth.uid() = user_id) and a user can update their own row and reassign user_id to somebody else. They passed the visibility check on the old row. Nothing validated the new one.

sql
create policy "Users can update their own profile."
on profiles for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );

Second-order gotcha: UPDATE also requires a SELECT policy to exist, or it will not work as expected.

And do not write FOR ALL. Supabase's guidance is four separate policies, one per command, because FOR ALL hides exactly the asymmetry above.

3. Policies built on user_metadata

This one is rated ERROR by Supabase's linter, rule 0015, and the exploit is a single line of client-side code.

user_metadata is designed to be manipulated by the user themselves. It is writable from the browser. So a policy like this:

sql
using ( ((select auth.jwt()) -> 'user_metadata' ->> 'is_admin')::bool )

is defeated by any user running:

javascript
supabase.auth.updateUser({ data: { is_admin: true } })

Supabase admits there is no one-size-fits-all replacement. The sanctioned route is a Custom Access Token Auth Hook that injects claims server-side before the token is issued, reading from a table you control:

sql
select role into user_role from public.user_roles where user_id = (event->>'user_id')::uuid;
claims := jsonb_set(claims, '{user_role}', to_jsonb(user_role));

Then read it back in policies with (auth.jwt() ->> 'user_role'). One wrinkle: the hook modifies the access token but not the auth response, so the browser has to decode the JWT to see the claim.

4. Views that bypass everything

This is the one nobody checks, and it is rated ERROR too.

Postgres views default to SECURITY DEFINER semantics. Supabase calls that "an unintuitive default, chosen for backwards compatibility." The consequence, in their words: a public security definer view referencing auth.users exposes all user records to all API users.

You can enable RLS on every table you own and still hand the whole thing over through a view.

sql
create view public.my_view with (security_invoker = on) as select ...;

The same class covers materialized views, which bypass RLS protections, and foreign tables, which cannot enforce it at all. Rules 0016 and 0017.

Make your policies fast

A policy runs against every row the database examines. Write it carelessly and a query that should take milliseconds takes seconds, which is why performance belongs in a security guide at all: in my experience people respond to a slow app by loosening the thing that slowed it down. That last part is my observation, not Supabase's. The numbers below are theirs, and they are not marginal.

Wrap function calls in a subselect. Writing (select auth.uid()) = user_id instead of auth.uid() = user_id causes the planner to run an initPlan and cache the result rather than calling the function once per row. Their tests: 11,000ms down to 7ms, and in another case 178,000ms down to 12ms.

Index the column the policy filters on. Reported as roughly a 99.94% improvement on a large table.

Always specify the role. Adding to authenticated took a case from 170ms to under 0.1ms for anonymous requests, because without it the policy is evaluated even for callers who could never pass it.

Flip your join direction. Slow: auth.uid() in (select user_id from team_user where team_user.team_id = table.team_id). Fast: team_id in (select team_id from team_user where user_id = auth.uid()).

Filter client-side as well. Adding .eq('user_id', userId) to a query that RLS already constrains took 171ms to 9ms.

Rule 0003 flags un-wrapped function calls for you.

Storage, Realtime, and the parts that are not tables

Storage

Storage objects need their own policies, written against storage.objects. Supabase does not allow uploads to buckets without them.

The public bucket risk is sharper than people assume. It is not only that files are readable by URL, which is the point of the flag. It is enumeration. Rule 0025 catches a public bucket that also carries a broad SELECT policy, which upgrades "readable if you know the URL" into "list everything in here." Their note: clients can list objects through Storage APIs, which is often broader access than the project intended.

Fix is to drop the listing policy. Object URLs keep working.

Realtime

Two genuinely dangerous behaviours here.

DELETE events are not filtered by RLS. Supabase's explanation: there is no way for Postgres to verify that a user has access to a deleted record. Combine that with replica identity full, which you need in order to filter deletes at all, and deleted-row payloads can reach subscribers who could never have selected that row.

Private channels only work if you close the public door. Opening a channel with private: true is not sufficient. You must also disable "Allow public access" in Realtime settings, otherwise a client simply omits the flag and skips authorization entirely.

Also worth knowing: policies are cached for the duration of the connection. Revoking someone's access does not disconnect a live subscriber.

Edge Functions

The verify_jwt setting only understands legacy JWT-format keys. The new sb_secret_ and sb_publishable_ keys are not JWTs, so the platform check rejects them. The documented workaround is to set verify_jwt = false and authorize in your own code.

That is a real footgun. People disable the check, ship, and never write the replacement.

Grants and RLS are two different layers

Worth stating because it is about to matter operationally. Grants decide which Postgres roles can reach a table over the Data API. RLS decides which rows those roles can see. Supabase's instruction is to use both controls on every exposed object.

A dated change is coming. From 30 October 2026, new tables stop being automatically exposed to the Data API on existing projects. New projects already default this way as of 30 May 2026.

The good news is the failure is loud rather than silent. PostgREST returns permission denied for table your_table with a hint containing the exact grant you need. Direct Postgres connections, ORMs and psql are unaffected.

sql
grant select on table public.your_table to anon;
grant select, insert, update, delete on table public.your_table to authenticated;

How to actually test a policy

Four options, in ascending order of rigour.

User impersonation in Studio. Use the Table Editor and SQL Editor as a specific user and see what they see.

The RLS Tester, a feature preview, shows which policies were evaluated and which failed. Important limitation: it only supports SELECT queries, so it cannot catch the UPDATE WITH CHECK bug above.

Manual session simulation in the SQL editor:

sql
set session role authenticated;
set request.jwt.claims to '{"role":"authenticated", "sub":"<user-uuid>"}';
explain analyze select * from your_table;

pgTAP with supabase test db for policies you want covered permanently. It ships assertions like policies_are(), policy_roles_are() and policy_cmd_is() for asserting policy shape, plus results_eq() for asserting behaviour.

If you do nothing else here, do the cheapest version: open your app logged out, and then logged in as a second user, and try to read the first user's data.

Run the Security Advisor

It is in the dashboard under Database, and it catches most of this article automatically. The rules worth knowing by name:

RuleLevelCatches
0013_rls_disabled_in_publicERRORRLS off on a public-schema table
0002_auth_users_exposedERRORUser data exposed through a view
0010_security_definer_viewERRORView bypasses RLS
0015_rls_references_user_metadataERRORPolicy trusts user-editable data
0023_sensitive_columns_exposedERRORPassword or card or medical-shaped columns with no RLS
0019_insecure_queue_exposed_in_apiERRORQueue exposed unprotected
0024_permissive_rls_policyWARNUSING (true) and friends
0003_auth_rls_initplanWARNSlow per-row policy
0006_multiple_permissive_policiesWARNPolicies compounding access unintentionally
0025_public_bucket_allows_listingWARNPublic bucket is enumerable
0028 / 0029 definer function executableWARNSECURITY DEFINER function callable from the API
0026 / 0027 graphql table exposedWARNTable reachable via /graphql/v1
0007_policy_exists_rls_disabledINFOPolicies written, RLS never switched on
0008_rls_enabled_no_policyINFORLS on, no policies, table reads empty

Rule 0007 deserves a highlight. You can write a full set of correct policies and never enable RLS, and everything will look done.

If a key leaked

Rotate. Deleting the commit is not remediation.

New-format secret key. Create a new one, swap it in, delete the old one. Multiple secret keys coexist, so this is zero downtime. Deletion is irreversible.

Legacy JWT secret. Violent. Once regenerated, all current API secrets are immediately invalidated and all connections using them are severed. Every session dies. Supabase's own advice now is to migrate to the new key format rather than rotate legacy secrets.

Asymmetric signing keys. Rotating is not enough. If you do not explicitly revoke the old key, it stays valid. Wait at least 20 minutes between steps, because JWKS is cached around 10 minutes at the edge.

Four things that break when you move to the new key format, all documented:

  1. New keys go in the apikey header, not Authorization: Bearer. Sending them the old way returns Invalid JWT.
  2. Database webhooks and pg_net traditionally send the service key as a bearer token, which now gets rejected.
  3. Edge Functions need verify_jwt = false plus your own check.
  4. Public Realtime connections are limited to 24 hours without user-level auth.

Sources

Supabase documentation

Incidents

A note on scope: Supabase does not publish a dedicated multi-tenant RLS guide. The org-scoped join pattern shown here comes from their performance documentation. Database webhook payloads are generated at trigger level and are therefore not RLS-filtered, but Supabase does not document that directly, so treat it as reasoning rather than a cited claim.