Grants vs row level security
Two checks run on every request. A grant comes first.
| Layer | Question it answers | What a missing one looks like |
|---|---|---|
| Grant | Can this role touch the table at all? | 42501, permission denied for table your_table |
| RLS policy | Which rows can this role see or change? | Reads return no rows. Writes fail with 42501 and a row-level security message. |
Row level security (RLS) is a Postgres feature that filters rows per request using rules called policies. A grant is a table-level permission for a role. You need both for every table the Data API exposes.
Things that surprise people
- Adding policies does not create a grant. A table with perfect policies and no grant still returns 42501.
- Adding policies does not remove a grant either. On older projects a table protected only by policies can still hand
anonan insert path if the grant was never revoked. - With RLS on and no policy, a publishable (anon) key reads nothing.
- Policies add up. A row is allowed if any one policy allows it, so an old
using ( true )policy defeats a newer strict one. service_rolebypasses RLS, and it still needs a grant.
Both layers, one migration
-- public.your_table: owner only
alter table public.your_table enable row level security;
revoke all on table public.your_table from anon, authenticated;
grant select, insert, update, delete on table public.your_table to authenticated;
grant select, insert, update, delete on table public.your_table to service_role;
drop policy if exists your_table_select_own on public.your_table;
create policy your_table_select_own on public.your_table
for select to authenticated
using ( (select auth.uid()) = user_id );
drop policy if exists your_table_insert_own on public.your_table;
create policy your_table_insert_own on public.your_table
for insert to authenticated
with check ( (select auth.uid()) = user_id );
drop policy if exists your_table_update_own on public.your_table;
create policy your_table_update_own on public.your_table
for update to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );
drop policy if exists your_table_delete_own on public.your_table;
create policy your_table_delete_own on public.your_table
for delete to authenticated
using ( (select auth.uid()) = user_id );Write the fix for your tables
One table is free. The whole schema with a rollback file is $9 one-time.
Waiting for a paste.
Who can reach each table
Your tables show up here, one row each, with a choice of who can reach them.
The SQL appears here as soon as a table is found.
Parsed in your browser. Your SQL is never uploaded, and no site or database is scanned.
Questions
Which do I fix first?
The grant. Supabase's RLS guide says that when a request fails that your policy should allow, check the grants before you change the policy.
Sources
- Supabase docs: Row Level Security, read 2026-11-10
- Supabase docs: Securing your API, read 2026-11-10
- Supabase docs: Postgres Roles, read 2026-11-10
- PostgreSQL manual: CREATE POLICY, read 2026-11-10
- Our own test: generated SQL run against PGlite (Postgres compiled to WebAssembly), read 2026-11-10
Related
- Which policy does each command need?One table: every command against the policies Postgres checks for it.
- Which grant does each call need?Tick what your code does. Copy the smallest grant that makes it work.
- All 30 Supabase advisor lintsName, level and one-line summary for every lint, 0001 to 0030.
- service_role gets "permission denied for table"The secret key skips policies. It does not skip grants. Add one line.