← Blog

Supabase RLS: the setting that quietly leaves your database open

If you built on Supabase with an AI and you’re about to launch, stop and check one thing first: is row-level security actually on, with real policies?

It’s the most common critical finding we see in vibe-coded apps, and it’s quietly catastrophic. The app works perfectly for you — because you only ever look at your own data. Meanwhile anyone with your (public) anon key can read the whole table.

What RLS actually is

Supabase gives your browser a database client and a public anon key. That key is meant to be public — it ships in your frontend. By itself it doesn’t grant access to anything; what gates access is row-level security (RLS): per-table rules that say which rows a given user is allowed to read or write.

No RLS, or a policy that says “allow everyone,” means the anon key can read and often write every row in every table. There is no other lock. The “login” in your UI doesn’t help — that’s app-level; RLS is database-level, and the database is what’s exposed.

Why vibe-coded apps ship with it off

Two reasons, both understandable:

  1. It’s off by default on new tables, and turning it on breaks everything until you add policies. When you enable RLS with no policy, every query returns nothing. So the path of least resistance while building is: leave it off, ship features, “deal with security later.” Later never comes.
  2. The quick “fix” is worse than off. To make queries work again, it’s tempting (and AI-suggested) to add a policy like using (true) — which means “every row, for everyone.” That technically enables RLS (the dashboard goes green) while granting exactly the access you were trying to prevent.

So you end up in one of two states that both look fine and both leak: RLS off, or RLS on with an open policy.

How to check it yourself (2 minutes)

In the Supabase dashboard:

  • Table editor → each table → confirm “RLS enabled” is on. A table with RLS off is wide open.
  • Authentication → Policies (or Database → Policies): read every policy. A policy USING (true) on SELECT means anyone can read it. You want policies scoped to the user — typically comparing a column to auth.uid().

Or run this and look for any table where rowsecurity is false:

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

Then for the policies themselves:

select tablename, policyname, cmd, qual
from pg_policies
where schemaname = 'public';

If qual is true (or null) on a readable table, that table is effectively public.

How to fix it

For each table holding user data:

  1. Enable RLS. alter table your_table enable row level security;
  2. Add a policy that scopes rows to the current user, e.g. a table with a user_id column:
create policy "Users read their own rows"
on your_table for select
using ( auth.uid() = user_id );
  1. Do the same for insert / update / delete — each command needs its own policy. A common miss is locking down select but leaving insert/update open.
  2. Multi-tenant? Scope by org_id derived from the session, never from a value the client sends.
  3. Re-test as a real, second user — not just as yourself — and confirm you can’t see anyone else’s rows.

The trap to avoid

Don’t “fix” it by moving everything behind your own API routes and keeping RLS open “because the API checks auth.” Now you have two systems that must agree, and the day someone hits the database directly with the anon key — or you add one more client query — the open policy is still there. RLS is the backstop that’s supposed to hold even when the app layer is wrong.


RLS is exactly the kind of issue that’s invisible until the wrong person looks — green dashboard, working app, fully exposed data. It’s one of the first things a senior engineer checks in a Supabase production-readiness audit: not just “is RLS on,” but “does each policy actually scope rows to the right user.” If you’re on Supabase and launching soon, it’s worth a real read before real users arrive — or run the free 2-minute self-check to see where you stand.