If your app is built on Supabase, Row Level Security (RLS) is not optional — it is the only thing between a logged-in user and everyone else's data. It is also the single most common thing vibe-coded and rushed apps get wrong.
This guide explains how RLS works, how to enable it, how to write policies that actually scope data to the right user, the mistakes that quietly leak everything, and how to verify RLS is protecting every table.
Why RLS matters in Supabase specifically
In a Supabase app, the browser talks to the database directly using the public anon key. That is by design — but it means the database itself has to enforce permissions, because the client cannot be trusted. RLS is that enforcement layer.
// This runs in the browser, with a key anyone can read:
const { data } = await supabase.from('orders').select('*')With RLS off, that query returns every order from every customer. With RLS on and a correct policy, it returns only the current user's orders. Same code — the database decides.
RLS is not on by default
A table created in the SQL editor has RLS disabled. Disabled RLS means no restriction: anyone with the anon key (which is public) can read and write the whole table. This is the classic Supabase data leak behind countless "my app got scraped" posts.
-- Enable RLS on a table (do this for EVERY table)
alter table orders enable row level security;Important gotcha: enabling RLS with no policies denies all access. The app appears broken (empty lists, failed inserts). The fix is to add policies — not to turn RLS back off.
Writing RLS policies
A policy scopes rows to the authenticated user. auth.uid() returns the current user's ID from their JWT.
-- Users can read only their own orders
create policy "read own orders"
on orders for select
using ( auth.uid() = user_id );
-- Users can insert orders only as themselves
create policy "insert own orders"
on orders for insert
with check ( auth.uid() = user_id );
-- Users can update only their own orders
create policy "update own orders"
on orders for update
using ( auth.uid() = user_id )
with check ( auth.uid() = user_id );using filters which existing rows are visible/affected; with check validates new or modified rows. Write both for write operations, or a user can insert rows owned by someone else.
The RLS mistakes that leak data
| Mistake | What happens |
|---|---|
| RLS never enabled on a table | Entire table is public read/write via the anon key |
using ( true ) policy | "Policy exists" but allows everyone — same as no protection |
insert with no with check | Users can create rows owned by other users |
| Service-role key on the client | Bypasses RLS entirely — full DB access from the browser |
| Public storage buckets | Uploaded files (invoices, IDs) readable by anyone with the URL |
| RLS on but no policy | App silently breaks; tempting "fix" is to disable RLS again |
How to verify RLS is actually protecting you
1. Find tables with RLS off:
select tablename, rowsecurity
from pg_tables
where schemaname = 'public' and rowsecurity = false;2. Find tables with RLS on but no policy (these deny all and signal a half-finished setup):
select t.tablename
from pg_tables t
left join pg_policies p
on p.schemaname = t.schemaname and p.tablename = t.tablename
where t.schemaname = 'public'
and t.rowsecurity = true
and p.policyname is null;3. Test from the client. Log in as User A and try to read User B's row by ID — it must return nothing.
4. Scan the live app.Nurbak probes your deployed Supabase app from the outside and flags tables that are publicly readable, plus exposed keys and missing security headers — the full vibe-coding attack surface, not just RLS. Paste your URL and get the report in seconds.

