Supabase Row Level Security: a practical guide
Row Level Security (RLS) is a Postgres feature that decides, row by row, which data each user can read or change. In Supabase it's what makes it safe to query the database straight from the browser: you turn RLS on for every table the Data API exposes, then write policies, such as "users can only see rows where user_id matches their own ID". Without RLS, anyone with your public key can read and write those tables.
Why RLS matters with the Data API
Supabase's Data API (PostgREST) turns your tables into REST endpoints. Your frontend calls them with the public key, and every request runs as one of two Postgres roles:
anon: the visitor isn't signed in.authenticated: the visitor is signed in. Their JWT carries their user ID, whichauth.uid()returns. Anonymous sign-ins also use this role, and you can tell them apart by theis_anonymousclaim in the JWT.
The public key is meant to be visible, since it ships with your frontend code. So the database is the only thing standing between a visitor and your data. Supabase's RLS docs say it plainly: a table in an exposed schema without RLS can be read and written by any role that has a grant on it.
Enabling RLS
alter table public.notes enable row level security;
Once RLS is on, the table is deny by default: no rows come back through the API until a policy allows them. That's what you want. Enable RLS first, then open up access one policy at a time.
A table created with plain SQL doesn't get RLS automatically, so add the alter table line to every migration that creates a table. If you want it switched on for every new table, Supabase's docs show how to do that with an event trigger.
Policies for each operation
A policy is a SQL condition attached to a table. Two clauses do the work:
USINGfilters existing rows: which rows you can see, update or delete.WITH CHECKvalidates new rows: what you're allowed to insert, or what a row may look like after an update.
| Operation | USING |
WITH CHECK |
|---|---|---|
select |
Yes | No |
insert |
No | Yes |
update |
Yes (old row) | Yes (new row) |
delete |
Yes | No |
Here is a full set for a notes table with a user_id uuid column:
create policy "Users can read their own notes"
on public.notes for select
to authenticated
using ( (select auth.uid()) = user_id );
create policy "Users can create their own notes"
on public.notes for insert
to authenticated
with check ( (select auth.uid()) = user_id );
create policy "Users can update their own notes"
on public.notes for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );
create policy "Users can delete their own notes"
on public.notes for delete
to authenticated
using ( (select auth.uid()) = user_id );
Postgres needs one policy per operation; you can't list several in one for clause. If several permissive policies apply to the same operation, they're combined with OR, so any one of them is enough to grant access (PostgreSQL docs).
Rows that fail USING are silently skipped. An update on someone else's note just changes zero rows, with no error. Rows that fail WITH CHECK raise an error and the whole statement is rolled back.
Why (select auth.uid()) instead of auth.uid()
Both work, but they perform very differently on large tables. Written bare, auth.uid() can be called once per row. Wrapped in select, Postgres runs it once per statement and reuses the result. Supabase recommends the wrapped form for all its helper functions, including auth.jwt().
Two more performance rules from the same docs:
Index the columns your policies filter on. A policy on
user_idneeds an index onuser_id:create index notes_user_id_idx on public.notes using btree (user_id);Always add a
TOclause. Without it a policy applies to every role, includinganon, and Postgres evaluates the expression for requests that could never pass.
TO authenticated vs anon
to authenticated limits a policy to signed-in users. to anon covers visitors who aren't signed in. For content anyone can read, such as published blog posts, name both:
create policy "Anyone can read published posts"
on public.posts for select
to anon, authenticated
using ( published = true );
Remember that auth.uid() is null for anon. A comparison with null is never true, so auth.uid() = user_id quietly matches nothing for signed-out users. That's safe, but it can make "why is my query empty?" confusing during development.
Ownership and team patterns
Single owner
The policies above are the ownership pattern. Two details make it reliable: give user_id a default so clients can't forget it, and reference auth.users.
alter table public.notes
alter column user_id set default auth.uid(),
add constraint notes_user_id_fkey foreign key (user_id) references auth.users (id) on delete cascade;
Team membership
For shared data, keep memberships in their own table and check it in the policy:
create table public.team_members (
team_id uuid not null references public.teams (id) on delete cascade,
user_id uuid not null references auth.users (id) on delete cascade,
role text not null default 'member',
primary key (team_id, user_id)
);
create index team_members_user_id_idx on public.team_members using btree (user_id);
The extra index matters. The primary key starts with team_id, so it doesn't help lookups by user_id alone.
The membership table needs RLS too, which creates a trap: if a policy on team_members queries team_members, Postgres reports infinite recursion. The usual fix is a security definer function in a schema the API doesn't expose. It runs as its owner, so it can read memberships without going through RLS again:
create schema if not exists private;
create function private.user_team_ids()
returns setof uuid
language sql
stable
security definer
set search_path = ''
as $$
select team_id from public.team_members where user_id = (select auth.uid());
$$;
create policy "Members can read team projects"
on public.projects for select
to authenticated
using ( team_id in (select private.user_team_ids()) );
set search_path = '' stops the function from resolving names through a schema someone else controls, so every name inside it must be schema-qualified.
Storage policies basics
Supabase Storage keeps file metadata in storage.objects, and access is controlled with RLS policies on that table (Storage access control). A common layout is one folder per user, named after their ID:
create policy "Users upload into their own folder"
on storage.objects for insert
to authenticated
with check (
bucket_id = 'documents'
and (storage.foldername(name))[1] = (select auth.jwt()->>'sub')
);
create policy "Users read their own files"
on storage.objects for select
to authenticated
using ( bucket_id = 'documents' and (select auth.jwt()->>'sub') = owner_id );
Uploading needs only an insert policy. Overwriting an existing file (upsert) also needs select and update.
Testing policies
Test policies as the roles your app actually uses. The SQL editor connects as postgres by default, and on Supabase that role has bypassrls, so a query that works there proves nothing. Supabase's pgTAP guide impersonates a user inside a transaction that's rolled back:
begin;
set local role authenticated;
set local request.jwt.claim.sub = '123e4567-e89b-12d3-a456-426614174000';
select * from public.notes; -- should return only this user's notes
rollback;
Test the failure paths as well: as anon, as a second user, and an update that tries to set user_id to someone else. A check that only confirms policies exist in pg_policies will pass even when the policy logic is wrong.
Common mistakes
Being vague about WITH CHECK on update. If an update policy has only USING, Postgres reuses it as the check, so users still can't hand a row to someone else. The real danger is a WITH CHECK that's looser than USING, like with check (true) copied from somewhere else, which lets a user rewrite a row they own into one they shouldn't. Write both clauses on every update policy so the intent is visible. Updates also need a matching select policy, or they won't work as expected.
Forgetting select for insert ... returning. supabase.from('notes').insert(row).select() returns the new row, which requires the row to pass a select policy too. Without one, the insert fails with an error.
Using user_metadata for authorization. raw_user_meta_data (user_metadata in the JWT) can be edited by the user through supabase.auth.updateUser(). Never base roles or permissions on it. Use app_metadata, which users can't change, or a table you control. Also, JWT claims don't update until the token refreshes.
Assuming RLS restricts columns. RLS works on rows. A user who can update their own profiles row can change every column in it, including is_admin. Limit updatable columns with column privileges:
revoke update on table public.profiles from authenticated;
grant update (display_name, avatar_url) on table public.profiles to authenticated;
Views that bypass RLS. By default a view runs with its owner's permissions, and views created in Supabase are usually owned by postgres, so they ignore your policies. On Postgres 15 and later, make the view respect the caller's RLS:
create view public.my_notes
with (security_invoker = true)
as select id, title from public.notes;
security definer functions in public. Functions in an exposed schema can be called through the API's /rpc endpoint. A security definer function there runs with its owner's rights for anyone who calls it. Keep them in a private schema, as in the team example.
Using the secret key in the browser. The secret and service_role keys bypass RLS completely. They belong on your server only.
Same rules on any Supabase host
RLS is plain Postgres, so these policies behave the same wherever Supabase runs. On SupaHost you write them in the same Supabase Studio (Studio docs), and the same SQL works unchanged. For moving an existing project, see migrating a Supabase project.
FAQ
Is RLS enabled by default in Supabase?
Don't rely on it. A table created with SQL needs alter table ... enable row level security, and Supabase recommends enabling it on every table in an exposed schema. Check each table before you ship.
What does auth.uid() return?
The signed-in user's ID from their JWT, as a uuid. For requests that aren't signed in, it returns null.
What's the difference between USING and WITH CHECK?
USING decides which existing rows a statement can see or touch. WITH CHECK decides whether new or changed rows are allowed. Update policies use both.
Why does my query return no rows after enabling RLS?
RLS denies everything until a policy allows it. Add a select policy for the right role, and check that the request is signed in if the policy uses auth.uid().
Does the service role key bypass RLS?
Yes. The secret and service_role keys skip RLS entirely, so only use them on servers you control, never in client code.
Do views respect RLS in Supabase?
Not by default. Create views with security_invoker = true (Postgres 15 and later) so the caller's policies apply.