supabase-rls-policies
Write, review, and test Supabase Row Level Security (RLS) policies so that one user cannot read or change another user's rows. Use when creating or changing tables, policies, views, or security definer functions in a Supabase project, when reviewing a migration that touches RLS, or when asked to prove user isolation with pgTAP tests that sign in as two users.
Try your first task
A source-grounded starter task has not been verified for this skill yet.
SKILL.md
name: supabase-rls-policiesdescription: 'Write, review, and test Supabase Row Level Security (RLS) policies so that one user cannot read or change another user''s rows. Use when creating or changing tables, policies, views, or security definer functions in a Supabase project, when reviewing a migration that touches RLS, or when asked to prove user isolation with pgTAP tests that sign in as two users.'Supabase RLS Policies
Write Row Level Security (RLS) policies for Supabase tables, then prove them with pgTAP tests that act as two different users. A policy is not finished until a test shows that a second user cannot read, change, delete, or create rows that belong to the first user.
When to Use This Skill
- Creating a table in an exposed schema (by default
public) - Adding or changing a policy, a view, or a
security definerfunction - Reviewing a migration or pull request that touches RLS
- Answering whether users or tenants are isolated from each other
Workflow
- List the access rules first. For each table, write down which role (
anonorauthenticated) may run which operation (select,insert,update,delete) on which rows. Confirm the list with the user before writing SQL. Each rule gets one test. - Write the migration. Enable RLS, add one policy per operation, and index every column the policies filter on.
- Write a pgTAP test file for the table under
supabase/tests/, using the template below. - Run
supabase test dband read every line of output. - Break the policy on purpose, on a local database only. Copy the test file and add a temporary policy with
using (true)right afterbegin;, for examplecreate policy "tmp_open" on public.notes for select to authenticated using (true);. Runsupabase test dbagainst the local stack fromsupabase start, confirm at least one test fails, then delete the copy. Do the same with a temporaryfor updateand afor deletepolicy. The file ends withrollback, so the temporary policy is undone even when the run fails or stops early. Never put a temporary policy in a migration, and never run this against a shared or production database. Do not drop the real policy to do this: with no policy every read is denied, so denial tests still pass and prove nothing. - Do not change a policy just to make a test pass. Report which assertion failed and which policy it covers, and let the user decide which one is wrong.
Rules for Every Table
Enable RLS and name the role
- Run
alter table ... enable row level securityon every table in an exposed schema. Once RLS is on and no policy exists, the API returns no rows. - Add a
toclause to every policy. Without it the policy applies toPUBLIC, which means every role, includinganon. auth.uid()returnsnullwhen the request has no signed in user.to authenticatedkeeps anonymous requests from reaching the check at all.
One policy per operation, with the owner checked on writes
alter table public.notes enable row level security;
create policy "notes_select_own" on public.notes
for select to authenticated
using ((select auth.uid()) = owner_id);
create policy "notes_insert_own" on public.notes
for insert to authenticated
with check ((select auth.uid()) = owner_id);
create policy "notes_update_own" on public.notes
for update to authenticated
using ((select auth.uid()) = owner_id)
with check ((select auth.uid()) = owner_id);
create policy "notes_delete_own" on public.notes
for delete to authenticated
using ((select auth.uid()) = owner_id);
create index notes_owner_id_idx on public.notes using btree (owner_id);
usingchecks rows that already exist.with checkchecks the row being written. Rows that failusingare skipped with no error; a row that failswith checkraises an error.- Bind the owner column in every insert and update check. A check such as
auth.uid() is not nullonly proves the caller is signed in. Any signed in user can then insert a row with another user's id in the owner column. - A column default such as
default auth.uid()does not protect the owner column. Postgres uses a default only when the insert does not supply a value, and the client can always supply one. - An
updateneeds a matchingselectpolicy, or the update will not work as expected. - Wrap
auth.uid()andauth.jwt()in(select ...)so Postgres runs them once per statement instead of once per row. - Index every column a policy filters on. A column counts as indexed only when it is the first column of a btree index.
Authorization data in the token
- Never base a policy on
user_metadata(raw_user_meta_data). Signed in users can change it themselves. - Store roles, teams, and plans in
app_metadata(raw_app_meta_data), which users cannot change, and read it withauth.jwt(). - A change to
app_metadatareachesauth.jwt()only after the user's token is refreshed. A user removed from a team keeps access until then.
Multi factor authentication
To require a second factor, add a restrictive policy:
create policy "notes_require_aal2" on public.notes
as restrictive
for all to authenticated
using ((select auth.jwt()->>'aal') = 'aal2');
Restrictive policies only narrow access. A table with only restrictive policies returns no rows, so keep the permissive policies above.
Views
Views bypass RLS by default because they run with the rights of the role that created them. On Postgres 15 and later, create views with security_invoker = true:
create view public.my_notes
with (security_invoker = true)
as select id, body from public.notes;
On older versions, remove every grant path or keep the view in a schema that is not exposed. Revoking only the direct grants is not enough: a grant to PUBLIC, a grant to a role the API roles are members of, or a grant on a single column still lets them read the view. Check the result with has_any_column_privilege, which counts all three; relacl lists only direct table grants.
revoke all on public.my_notes from public, anon, authenticated;
select has_any_column_privilege('anon', 'public.my_notes', 'select'),
has_any_column_privilege('authenticated', 'public.my_notes', 'select'); -- both must be false
Security definer functions
- A
security definerfunction runs as its owner. If the owner ispostgres, it bypasses RLS. - Never create one in an exposed schema. It can be called over the Data API with the owner's rights.
- Set
search_path = ''and schema qualify every name inside the function.
Keys
- Secret keys (and the legacy
service_rolekey) bypass RLS. Use them only on a server, never in a browser or mobile app. - A secret key bypasses RLS only when the request has no user access token. If the request carries one, that user's policies apply.
Two User pgTAP Test Template
Save as supabase/tests/notes_rls_test.sql and adapt the table, columns, and ids. The file runs in one transaction and rolls back, so test data never persists.
begin;
create extension if not exists pgtap with schema extensions;
select plan(8);
-- Two users, created before switching roles.
insert into auth.users (id, email) values
('11111111-1111-1111-1111-111111111111', 'user1@test.com'),
('22222222-2222-2222-2222-222222222222', 'user2@test.com');
insert into public.notes (id, owner_id, body) values
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', '11111111-1111-1111-1111-111111111111', 'user 1 note');
-- As user 1: the owner can read and change their own row.
set local role authenticated;
set local request.jwt.claims = '{"sub": "11111111-1111-1111-1111-111111111111", "role": "authenticated"}';
select results_eq(
$$select body from public.notes where id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'$$,
$$values ('user 1 note'::text)$$,
'user 1 reads their own note'
);
select results_eq(
$$update public.notes set body = 'edited' where id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' returning body$$,
$$values ('edited'::text)$$,
'user 1 updates their own note'
);
-- As user 2: every attempt on user 1's row must fail.
-- User 2 owns no rows yet, so the write probes need no where clause.
set local request.jwt.claims = '{"sub": "22222222-2222-2222-2222-222222222222", "role": "authenticated"}';
select is_empty(
$$select 1 from public.notes where id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'$$,
'user 2 cannot read user 1 note'
);
select is_empty(
$$update public.notes set body = 'changed' returning 1$$,
'user 2 cannot update user 1 note'
);
select is_empty(
$$delete from public.notes returning 1$$,
'user 2 cannot delete user 1 note'
);
select throws_ok(
$$insert into public.notes (owner_id, body) values ('11111111-1111-1111-1111-111111111111', 'forged')$$,
'42501', null,
'user 2 cannot create a note owned by user 1'
);
select results_eq(
$$insert into public.notes (owner_id, body) values ('22222222-2222-2222-2222-222222222222', 'user 2 note') returning owner_id$$,
$$values ('22222222-2222-2222-2222-222222222222'::uuid)$$,
'user 2 creates their own note'
);
-- No where clause: the update policy alone picks user 2's row,
-- so only its with check can stop the transfer.
select throws_ok(
$$update public.notes set owner_id = '11111111-1111-1111-1111-111111111111'$$,
'42501', null,
'user 2 cannot hand their note to user 1'
);
select * from finish();
rollback;
Notes on the template:
-
Use
returningon writes. An update or delete that RLS filters out succeeds with zero rows, so only the returned rows show what happened. -
In user 2's update and delete probes, do not filter on a column or return one. When an update or delete reads a column, Postgres applies the
selectpolicy as well, so a correctselectpolicy can hide anupdateordeletepolicy that is too open. Return the constant1and rely on user 2 owning no rows yet. -
Filter reads by the ids the test inserted. A count over the whole table breaks once other data exists.
-
Error
42501is the code Postgres raises when a written row fails awith checkexpression. -
Add an
anoncase for any table that anonymous visitors must not read, and add one to the plan count. Replace the claims first: changing the role does not clear them, soauth.uid()would still return user 2's id.set local request.jwt.claims = '{"role": "anon"}'; set local role anon; select is_empty( $$select 1 from public.notes where id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'$$, 'an anonymous visitor cannot read user 1 note' );If your project does not grant table privileges to
anon, the read raises42501instead, so usethrows_ok.
Testing Policies That Read Other Claims
The template sets the whole claims object in request.jwt.claims, the setting PostgREST fills from the token. auth.uid() and auth.jwt() both read it. If a test sets only request.jwt.claim.sub, auth.uid() still works but auth.jwt() returns null, so a policy that compares a claim such as aal or a value in app_metadata sees null. The aal2 policy above denies every user in that state, so a test that expects a denial passes for the wrong reason.
For a table with the restrictive aal2 policy, add "aal": "aal2" to the claims at both user switches in the template, so the owner checks still pass. Then test the other side before select * from finish();, and add these two assertions to the plan count:
-- If the anon case ran first, the SQL role is still anon. The role claim in the JSON does not change it.
set local role authenticated;
set local request.jwt.claims = '{"sub": "11111111-1111-1111-1111-111111111111", "role": "authenticated", "aal": "aal1"}';
select is_empty(
$$select 1 from public.notes where id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'$$,
'an aal1 session cannot read user 1 note'
);
set local request.jwt.claims = '{"sub": "11111111-1111-1111-1111-111111111111", "role": "authenticated", "aal": "aal2"}';
select isnt_empty(
$$select 1 from public.notes where id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'$$,
'an aal2 session reads user 1 note'
);
Review Checklist
- RLS is enabled on every table in an exposed schema
- Every policy names a role with
to - Insert and update checks bind the owner column to
(select auth.uid()) - No policy reads
user_metadata - Views use
security_invoker = true, or are not exposed - Security definer functions live outside exposed schemas and set
search_path = '' - Each policy column has an index that leads with that column
- Each user data table has a two user test covering read, update, delete, and insert
- The second user's update and delete probes read no column
- Tests set the full
request.jwt.claimsobject at each user switch, and replace it before theanoncase - The suite fails when a temporary
using (true)policy is added inside the test transaction on a local database
References
- Row Level Security, Supabase docs
- Test Row Level Security policies with pgTAP, Supabase docs
- Multi-Factor Authentication, Supabase docs
- CREATE POLICY, PostgreSQL docs
- Transactions, PostgREST docs (
request.jwt.claims) - CREATE TABLE, PostgreSQL docs (column defaults)
- auth.uid() source and auth.jwt() source, Supabase Auth