Concept · Row Level Security

Row Level Security in Supabase: what it protects, and what turning it off costs

Luca Urti

What is Row Level Security in Supabase, and when do I need it?

Row Level Security is access control inside PostgreSQL itself: it decides, row by row, who may read or write. In a Supabase project it is not extra hardening layered on top of something else, it is the only access control standing between a table in the public schema and the internet, because the anon key that ships in every browser bundle is allowed to reach that schema through PostgREST. A table in the public schema with RLS switched off is therefore readable by anyone holding the project URL and the anon key, and both of those sit in the page source that every visitor already has.

Why the anon key is public on purpose

The anon key is meant to be public. It identifies the project and the role anon, not a person, and it belongs in the client: hiding it would achieve nothing, because the browser has to send it on every request. On its own it is not a vulnerability, and treating it as one buries the finding that matters under a warning that never did.

What makes it dangerous is not the key, it is what sits open behind it. PostgREST answers every request this key makes that RLS lets through. With RLS off, RLS lets everything through.

Enabling RLS denies everything until a policy says otherwise

alter table public.leads enable row level security; turns the check on. With no policy attached the result is that nobody may do anything, except the service_role key, which bypasses RLS by design. That is the safe direction, and it changes the behaviour of the app immediately: reads that worked a minute ago come back as an empty list rather than as an error, which is why this step is so often mistaken for having broken something.

The policy is the actual work. using (auth.uid() = user_id) is the ordinary case, but which column carries ownership in your schema is the one thing no tool can determine from outside.

using (true) is the fix that looks like a fix

create policy "…" on public.orders for all using (true); switches RLS on and then permits everything. It appears constantly, because it is the shortest way out of the empty list that enabling RLS just produced.

For data that genuinely is public, a blog post or a published listing, it is correct. For anything belonging to a user it is a leak with the check switched on, and every dashboard will report that table as protected. A policy that protects nothing is harder to notice than no policy at all, which is why it is worth its own finding rather than a footnote.

The three ways around RLS, and what each one costs

They are not interchangeable. alter table public.orders disable row level security; removes the check outright, which returns the table to being readable by anyone with the anon key. The service_role key ignores RLS on every table at once, which is reasonable on a server you control and unrecoverable the moment that key reaches a browser. A security definer function runs with the privileges of whoever defined it, scoped to one operation you wrote deliberately.

Only the third is a design decision. The first two are the check being removed rather than satisfied, and the distinction is the whole subject: a query that stops failing because the rule was deleted has not started working, it has stopped being checked.

Checking without trusting the dashboard

The fastest self-test is the request an outsider would make: curl "https://<project>.supabase.co/rest/v1/<table>?select=*&limit=1" -H "apikey: <anon key>". If a row comes back, that table is readable by every visitor to your site, whether or not they ever sign in.

A dashboard reports configuration and this reports behaviour, and the two disagree more often than they should. One row returned to a public key is not a warning about a possible problem, it is the problem, reproduced.

The fix

Enable the check, then say who owns a row

-- 1. Turn the check on. Every read returns [] until a policy allows it.
alter table public.orders enable row level security;

-- 2. Say who owns a row. This is the half no tool can infer for you.
create policy "owners read their own orders"
  on public.orders for select
  using (auth.uid() = user_id);

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

The fix that works and costs you the database

The shortcut is disable row level security, or a policy of using (true), or moving the query onto a server and running it with the service_role key. All three make the empty list go away in one line, and all three answer a question that was never asked: the empty list is not a bug, it is the check reporting that nothing has yet said who owns this row. Deleting the check deletes the answer along with the question, and the table goes back to being readable by anyone holding a key that ships in your JavaScript bundle.

Whether the trap is already in your repo is a question you can answer

Sentris reads the SQL and the client code, so it reports the shortcut above where it was actually taken: a service_role key in a browser bundle, a policy that is using (true), a table with RLS switched off. A scan needs no account and no card. How often it is wrong is measured and published.

Scan my app