Supabase Row Level Security: The Complete Guide
Row level security decides which rows each user can see, and in Supabase it is the only thing standing between a logged-in user and your whole table, because the database is exposed directly to the browser. Enable it on every table holding user data, then write a separate policy for SELECT, INSERT, UPDATE and DELETE. The four mistakes that cause real leaks are a policy of USING (true), covering SELECT but not the write commands, putting a role column in a user-writable table, and never testing as a second user.
Supabase hands your database to the browser. That is the whole design, and it is why the product is so fast to build on. It also means row level security is not a hardening step you do later. It is the only thing between a logged-in user and every row in your table.
This is the reference version: every policy you need, the four mistakes that actually leak data, and how to prove your rules work.
How it works, in one paragraph
Every request from the browser carries the user's JWT. Postgres reads it, and auth.uid() returns that user's id inside a policy. A policy is a condition attached to a table and a command; Postgres adds it to every query automatically. If no policy allows a row, the row does not exist as far as that query is concerned. With row level security enabled and no policies at all, nothing is readable, which is the safe default.
The baseline, for a table each row belongs to one user
alter table public.projects enable row level security;
create policy "read own projects"
on public.projects for select
using (auth.uid() = user_id);
create policy "create own projects"
on public.projects for insert
with check (auth.uid() = user_id);
create policy "update own projects"
on public.projects for update
using (auth.uid() = user_id)
with check (auth.uid() = user_id);
create policy "delete own projects"
on public.projects for delete
using (auth.uid() = user_id);
Two things to notice.
using and with check are different. using decides which existing rows the command may touch. with check decides whether the row is allowed to exist after the write. An update policy needs both: using stops you editing someone else's row, with check stops you reassigning your own row to someone else.
Four commands, four policies. More on why below.
Mistake 1: a policy of USING (true)
-- Looks protected. Is not.
create policy "allow all" on public.orders for all using (true);
This passes every row. It is identical to having no policy, except the dashboard now shows the table as protected.
It appears for a predictable reason. Someone enables row level security, the app immediately breaks because no policy exists, and they add a permissive one to get moving again. The intention is to come back to it.
Find them all:
select tablename, policyname, cmd, qual
from pg_policies
where schemaname = 'public' and qual = 'true';
Mistake 2: covering select and forgetting the rest
This is the one that quietly does the most damage.
A policy applies only to the command it names. A table with a correct select policy and no update or delete policy, with row level security on, blocks writes entirely. But if someone later adds a permissive write policy to unblock the app, users can now modify and delete rows they cannot even read.
Check which commands each table actually covers:
select tablename,
count(*) filter (where cmd = 'SELECT') as sel,
count(*) filter (where cmd = 'INSERT') as ins,
count(*) filter (where cmd = 'UPDATE') as upd,
count(*) filter (where cmd = 'DELETE') as del
from pg_policies
where schemaname = 'public'
group by tablename
order by tablename;
Any row with a zero where you expected a policy is worth a look.
Mistake 3: a role column on a table users can write
-- profiles has: id, display_name, avatar_url, role
create policy "update own profile"
on public.profiles for update
using (auth.uid() = id)
with check (auth.uid() = id);
Correct as written, and it lets any user make themselves an administrator, because role is a column on a row they are allowed to update. One API call and they are an admin.
Roles belong somewhere users cannot write:
create table public.user_roles (
user_id uuid primary key references auth.users on delete cascade,
role text not null default 'member'
);
alter table public.user_roles enable row level security;
-- Readable by the owner, writable by nobody through the API.
create policy "read own role"
on public.user_roles for select
using (auth.uid() = user_id);
No insert, update or delete policy means the API cannot change it at all. Only a server-side process using the service key can.
Mistake 4: never testing as a second user
Everything above can look right and still be wrong. The only test that counts is an actual second account.
// Signed in as user B. Ask for a row that belongs to user A.
const { data, error } = await supabase
.from('projects')
.select('*')
.eq('id', USER_A_PROJECT_ID);
console.log(data); // [] is correct. A row means the policy does not work.
Do the same for update and delete, because they are governed separately:
await supabase.from('projects').delete().eq('id', USER_A_PROJECT_ID);
// Then check as user A whether the row still exists.
Testing through your own interface proves nothing. Your interface never asks for other people's rows, so it cannot discover that it is allowed to.
Multi-tenant: the same idea, one level up
When rows belong to an organisation rather than a person:
create table public.memberships (
user_id uuid references auth.users on delete cascade,
org_id uuid references public.orgs on delete cascade,
primary key (user_id, org_id)
);
create policy "read own org documents"
on public.documents for select
using (
org_id in (
select org_id from public.memberships
where user_id = auth.uid()
)
);
Put an index on memberships(user_id) and on documents(org_id), or this policy runs a subquery against an unindexed table on every single read.
Performance
Policies become part of every query's filter, so they obey the same rules as any WHERE clause.
Index the column the policy checks. user_id, org_id, whatever it is. Without it you have added a full scan to every request.
Evaluate auth.uid() once. Written bare, Postgres may call it per row. Wrapping it in a subquery lets the planner treat it as a constant:
using ((select auth.uid()) = user_id)
On a large table this is the difference between milliseconds and seconds, and it is a one-character-level change.
The service key bypasses all of this
service_role ignores every policy. That is its purpose, and it is why it must never reach the browser. Use it only in server code, for admin tasks and background jobs. If it is in your frontend bundle, none of this guide applies to your app, because anyone can read everything regardless of what your policies say.
What RLS does not do
It decides which rows a user may touch. It does not validate what they write, stop them setting a field they should not control, or protect anything running with the service key. Those still need checks in your application code.
Check your own project
Run these two now:
-- Tables with no protection at all
select tablename from pg_tables
where schemaname = 'public' and rowsecurity = false;
-- Policies that permit everything
select tablename, policyname, cmd from pg_policies
where schemaname = 'public' and qual = 'true';
Anything returned is worth your afternoon.
Or paste your live URL into our free verification check, which asks your database for a row as an anonymous stranger and tells you what came back. That is evidence rather than configuration, which is the one thing you cannot get from reading your own policies.
Related
Backend engineering is where most of this work sits for us, and the SaaS customer portal case study is a multi-tenant app with these rules in place.
If your app was built with an AI tool, the production readiness checklist covers the other three things that usually go with open policies.
Common questions
- What is row level security in Supabase?
- A Postgres feature that filters which rows a query can touch, based on who is asking. Supabase exposes your database straight to the browser, so every request arrives with the user's identity attached and the database itself decides what they may see. Without row level security enabled, any logged-in user can read every row in the table.
- Do I need a separate policy for SELECT, INSERT, UPDATE and DELETE?
- Yes, and this is the most common serious mistake. A policy applies only to the command it names. A correct SELECT policy with no UPDATE or DELETE policy means users can read only their own rows while being able to modify or delete anybody's. Write all four explicitly, or use FOR ALL and understand that it covers everything.
- What does USING (true) do and why is it dangerous?
- It tells Postgres that every row passes the check, which is identical to having no policy at all. It usually appears because someone enabled row level security, found the app broken, and added a permissive policy to make it work again. The table then looks protected in the dashboard while being completely open.
- How do I test that my RLS policies work?
- Create two accounts. Log in as the second one and try to read a record belonging to the first, directly through the client rather than through your interface. An empty result is correct; a returned row means the policy does not work. Do this for reads, updates and deletes, because they are governed separately.
- Does row level security slow down my queries?
- It can, because the policy becomes part of every query's filter. The usual fix is an index on the column the policy checks, normally user_id or tenant_id. Wrapping auth.uid() in a subquery so Postgres evaluates it once rather than per row also helps noticeably on large tables.
- Is RLS enough on its own?
- For deciding who sees which rows, yes. It does not validate input, stop a user setting their own role if that column is writable, or protect anything that runs with the service key. Those need checks in your application code as well.
Want this looked at properly?
Tell us what you're working with. We'll give you a straight answer in a 30-minute call, free.