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, free during launch.
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
- permission denied for sequenceThe table is granted and its id counter is not. One more grant fixes it.
- Check the grants on a tableOne query lists every role and privilege on a table.
- The Supabase change of 30 October 2026New tables in public need explicit grants. Dates, scope and the SQL in one place.
- Lovable app says "permission denied for table"The new table has no grants. Paste it below and copy a fix prompt made for Lovable.