Browse the blog
AI-built apps · Guide

Supabase RLS: test grants, policies and user access

A table grant does not bypass enabled Row Level Security. Ordinary roles need both a grant and a matching policy for the operation. With no applicable policy, RLS denies access. Review grants as defense in depth, test ownership checks, and keep service-role keys and owner-powered views out of public access.

Turning on Row Level Security (RLS) protects rows only when your policies describe the access your app should allow. Grants and policies are separate checks: passing one does not skip the other.

Correction, 9 October 2026: An earlier version said an insert grant could let an anonymous visitor write through enabled RLS without an insert policy. That was wrong. It also implied a missing explicit WITH CHECK always allows arbitrary ownership. A FOR ALL or UPDATE policy can inherit its USING expression as WITH CHECK.

What is the difference between a grant and a policy?

A grant lets a role attempt an operation on a table. A policy limits which rows an ordinary role can read or change when RLS is enabled. Both checks must allow the operation.

For ordinary roles such as anon and authenticated:

Configuration Result
Insert grant, RLS enabled, no policies Inserts denied
Insert grant, RLS enabled, SELECT policy only Inserts denied
Insert grant, RLS enabled, applicable INSERT policy Inserts must pass its WITH CHECK expression
Insert grant, RLS enabled, applicable FOR ALL policy If WITH CHECK is omitted, USING also checks inserted rows
No insert grant Inserts denied even if a policy would allow the row

These behaviors are documented in PostgreSQL's row security guide. Supabase's RLS guide explains how they apply to its API roles.

A table with RLS disabled may expose data through the API if its schema is exposed and the caller has the necessary grants. Disabled RLS alone does not prove that every visitor can read the table.

Why review grants if RLS already blocks unwanted rows?

Removing unnecessary grants is defense in depth. It reduces the operations available if someone later disables RLS or adds an overly broad policy. It is not a missing condition required to make enabled, correctly configured RLS work.

Check your actual project grants rather than assuming how your AI builder or project defaults created them. For a table used only by signed-in users, a starting point is:

alter table public.reports enable row level security;

revoke all on table public.reports from anon, authenticated;
grant select, insert on table public.reports to authenticated;

Review this before running it: your app may need update or delete permissions, and other grants or role membership can still confer access. Identity columns and sequences may need separate permissions. Make changes in a test project first.

How do I write ownership policies?

For a reports table with a user_id column, separate policies make the intended operations easy to review:

create policy "Users can view their own reports"
on public.reports for select
to authenticated
using ( (select auth.uid()) = user_id );

create policy "Users can create their own reports"
on public.reports for insert
to authenticated
with check ( (select auth.uid()) = user_id );

The insert check rejects a row that belongs to another user. An insert policy uses WITH CHECK; a select policy does not authorize writes.

A FOR ALL policy is valid too. When its WITH CHECK is omitted, PostgreSQL uses its USING expression to check new rows. Omitting the explicit clause does not automatically create an ownership bypass.

For a signed-out request, auth.uid() returns null. The expression null = user_id is not true, so it denies access. Adding auth.uid() is not null can make that intent clearer; it does not repair a bypass in the equality check.

What about service-role keys and views?

Supabase service-role credentials bypass RLS. They belong on trusted servers, never in a browser bundle. Supabase anon and publishable keys are intended for public clients; their presence alone is not a secret leak. Their safety depends on the permissions and policies protecting your data.

Superusers and roles with BYPASSRLS bypass row security. Table owners normally do too. Test as the real application role, not just as the database owner in the SQL editor.

A view normally checks access to its underlying tables with the view owner's permissions. If that owner bypasses RLS, granting public access to the view can expose rows. Not every view necessarily bypasses RLS: its owner and configuration matter.

On PostgreSQL 15 and above, an invoker view uses the caller's permissions and applicable policies:

create view public.report_summary
with (security_invoker = true)
as select user_id, count(*) as report_count
from public.reports
group by user_id;

Review existing views and restrict access to any that should not be public. Check both the view permissions and the role used to access it.

How do I check my own project right now?

  1. Check RLS is enabled on tables exposed through the API.
  2. Review grants and policies for anon and authenticated separately.
  3. Test as a signed-out visitor, user A and user B. Assert which rows each can read, insert, update and delete.
  4. Try inserting a row owned by another user and updating a row's owner. Both should fail unless your product explicitly permits it.
  5. Check publicly accessible views and make sure no service-role credentials ship to the browser.

Use a staging project or transactional database fixtures. supabase test db runs database tests. For an allowed write, assert the affected row or RETURNING value: an error-free update that changes zero rows does not prove permission. For a denied read, assert that unauthorized rows are absent, since RLS can filter results without raising an error.

What should each role be able to do?

For a table where every row belongs to one user, this is the result to expect. A denied read or update usually returns zero rows rather than an error, which is why the test below counts rows instead of waiting for a failure.

Action Signed-out visitor User A User B
Read user A's rows 0 rows Allowed 0 rows
Insert a row owned by user A Error Allowed Error
Update user A's rows 0 rows changed Allowed 0 rows changed
Move a row from user A to user B 0 rows changed Error 0 rows changed
Delete user A's rows 0 rows deleted Only with a delete policy 0 rows deleted

Can I run that check myself?

Yes. The script below builds a throwaway table, adds owner-only policies, and then acts as a signed-out visitor, user A and user B in turn. It impersonates users the same way Supabase's testing guide does, with set local role authenticated and the request.jwt.claim.sub setting that auth.uid() reads. Everything runs inside one transaction that ends in rollback, so nothing is left behind.

begin;

create table public.rls_matrix_demo (
  id bigint generated always as identity primary key,
  user_id uuid not null,
  body text
);
alter table public.rls_matrix_demo enable row level security;
grant select, insert, update, delete on public.rls_matrix_demo to anon, authenticated;

create policy "own rows: read" on public.rls_matrix_demo
  for select to authenticated using ((select auth.uid()) = user_id);
create policy "own rows: insert" on public.rls_matrix_demo
  for insert to authenticated with check ((select auth.uid()) = user_id);
create policy "own rows: update" on public.rls_matrix_demo
  for update to authenticated using ((select auth.uid()) = user_id);

insert into public.rls_matrix_demo (user_id, body) values
  ('aaaaaaaa-0000-0000-0000-000000000001', 'user A note'),
  ('bbbbbbbb-0000-0000-0000-000000000002', 'user B note');

do $$
declare
  a constant uuid := 'aaaaaaaa-0000-0000-0000-000000000001';
  b constant uuid := 'bbbbbbbb-0000-0000-0000-000000000002';
  n bigint;
begin
  -- Signed-out visitor
  perform set_config('role', 'anon', true);
  select count(*) into n from public.rls_matrix_demo;
  raise notice '% anon reads 0 rows (got %)', case when n = 0 then 'PASS' else 'FAIL' end, n;
  begin
    insert into public.rls_matrix_demo (user_id, body) values (a, 'anon write');
    raise notice 'FAIL anon insert was allowed';
  exception when insufficient_privilege then
    raise notice '% anon insert denied (%)', case when sqlerrm like '%row-level security%' then 'PASS' else 'FAIL' end, sqlerrm;
  end;

  -- User A
  perform set_config('role', 'authenticated', true);
  perform set_config('request.jwt.claim.sub', a::text, true);
  insert into public.rls_matrix_demo (user_id, body) values (a, 'user A second note');
  get diagnostics n = row_count;
  raise notice '% user A inserts own row (inserted %)', case when n = 1 then 'PASS' else 'FAIL' end, n;
  select count(*) into n from public.rls_matrix_demo;
  raise notice '% user A reads only own rows (got %)', case when n = 2 then 'PASS' else 'FAIL' end, n;
  select count(*) into n from public.rls_matrix_demo where user_id = b;
  raise notice '% user A sees 0 of user B rows (got %)', case when n = 0 then 'PASS' else 'FAIL' end, n;
  begin
    insert into public.rls_matrix_demo (user_id, body) values (b, 'forged owner');
    raise notice 'FAIL user A inserted a row owned by user B';
  exception when insufficient_privilege then
    raise notice '% user A cannot insert as user B (%)', case when sqlerrm like '%row-level security%' then 'PASS' else 'FAIL' end, sqlerrm;
  end;
  update public.rls_matrix_demo set body = 'changed by A' where user_id = b;
  get diagnostics n = row_count;
  raise notice '% user A updates 0 of user B rows (changed %)', case when n = 0 then 'PASS' else 'FAIL' end, n;
  begin
    update public.rls_matrix_demo set user_id = b where user_id = a;
    raise notice 'FAIL user A handed a row to user B';
  exception when insufficient_privilege then
    raise notice '% user A cannot change a row''s owner (%)', case when sqlerrm like '%row-level security%' then 'PASS' else 'FAIL' end, sqlerrm;
  end;

  -- User B
  perform set_config('request.jwt.claim.sub', b::text, true);
  select count(*) into n from public.rls_matrix_demo;
  raise notice '% user B reads only own row (got %)', case when n = 1 then 'PASS' else 'FAIL' end, n;
  delete from public.rls_matrix_demo where user_id = a;
  get diagnostics n = row_count;
  raise notice '% user B deletes 0 of user A rows (deleted %)', case when n = 0 then 'PASS' else 'FAIL' end, n;
end $$;

rollback;

Run it with psql against a local stack (supabase start) or a staging project, never production. Each check prints a NOTICE line that starts with PASS or FAIL. Each denied write passes only if PostgreSQL refused it for row-level security, so an unrelated permission error cannot pass by accident. We ran it on PostgreSQL 18.6 with a stand-in for auth.uid(): all ten checks passed. Adding a read policy with using (true) turned the four read checks to FAIL, and loosening the insert policy to with check (true) turned "cannot insert as user B" to FAIL. Those runs matter, because a test that cannot fail proves nothing. To check your own table, swap in its name, its owner column and your real policies, and keep the expected results from the matrix above.

Is this the whole picture?

RLS protects database rows. It does not protect an exposed API key, an admin route with no login, or a paywall enforced only in the browser.

Sentrint scans your repository for code security issues and returns ranked findings with fix prompts. It is a code scan, not a pentest or a live verification of your database permissions. The first scan is free and needs no card. Run your free scan, then test the database permissions with the roles your app actually uses.