Row Level Security is off by default on every new Supabase table. Until you turn it on, anyone holding your public anon key, which ships to every visitor's browser, can read and write the entire table through the data API. The fix: enable RLS, then add a per-table policy that scopes access to the row's owner.
What RLS is and why it matters
Row Level Security is a Postgres feature that filters which rows a query is allowed to touch, based on rules you define. Supabase exposes every table in the public schema through its data API, so without RLS, that API has no concept of "your data" versus "someone else's data." It just returns rows.
The anon key that your frontend uses is meant to be public. It is not a secret, it is an identifier. The thing that is supposed to stand between that key and your users' rows is RLS. If it is off, the key alone is enough to read or write everything.
How to check if RLS is off
Run this in the Supabase SQL editor to list every public table where RLS is not enabled:
select tablename
from pg_tables
where schemaname = 'public'
and rowsecurity = false;
Anything that comes back is currently readable and writable by anyone with your anon key, no login required. You can also see this per-table in the dashboard, under Table Editor, where tables without RLS show an unlocked icon.
Enable RLS on a table
For each table the query above returned, turn RLS on:
alter table public.<t> enable row level security;
This does not add access, it removes it. The moment RLS is enabled with no policies, every request to that table is denied by default, including your own app's requests. That is expected, and it is why the next step is not optional.
Write a safe owner-scoped policy (with SQL)
The standard pattern ties a row to the user who owns it, usually through a user_id column, and checks that column against the currently authenticated user.
create policy "Users can read own rows"
on public.todos
for select
using (auth.uid() = user_id);
create policy "Users can insert own rows"
on public.todos
for insert
with check (auth.uid() = user_id);
create policy "Users can update own rows"
on public.todos
for update
using (auth.uid() = user_id);
create policy "Users can delete own rows"
on public.todos
for delete
using (auth.uid() = user_id);
Write one policy per operation instead of a single for all policy. using controls which existing rows a query can see or touch, with check controls what a new or updated row is allowed to contain, and the two are not interchangeable, an insert policy needs with check, not using.
If a table needs to be readable by anyone but only writable by its owner, split it: a permissive select policy with using (true), and owner-scoped policies for insert, update and delete.
Common mistakes
The same handful of mistakes cause most RLS bugs:
- Enabling RLS but only writing a
selectpolicy, then wondering why inserts fail. Every operation your app performs needs its own policy. - Using
for allwith a single loose condition, which is easy to write wrong and hard to audit later. - Checking the wrong column, for example
auth.uid() = idon atodostable whereidis the todo's own id, not the owner's. - Forgetting that Storage has its own, separate set of policies. Enabling RLS on your tables does nothing for a public Storage bucket.
- Assuming a policy that "looks right" is actually enforced without testing it as an unauthenticated or wrong-user request.
How to verify you are safe
Test as an outsider would, with only the anon key:
curl "https://<project>.supabase.co/rest/v1/todos?select=*" \
-H "apikey: <anon-key>" \
-H "Authorization: Bearer <anon-key>"
An unauthenticated request should return an empty array or a 401/403, never rows. Then sign in as two separate test users and confirm each one only ever sees their own data. Repeat this for every table you fixed, not just the first one.
Check your whole app in 30 seconds with a free LeakRank scan