Permission denied for table

The database role making the request has no grant on that table, so Postgres refuses before row-level security even runs.

Supabase and browser console
permission denied for table todos

Also appears as

  • permission denied for relation todos

Prompt to fix it

Requests fail with "permission denied for table todos" (code 42501). This error means the table is missing a GRANT for the role making the request. Check which role that is (anon, authenticated, or service_role) and which grants the table has. Write a migration that grants only the actions the app needs to the right roles, enables row-level security, and adds policies so each user reaches only their own rows. Keep RLS on, grant nothing to anon unless signed-out visitors need it, and show me the SQL before you run it.

Paste into Layerwise or any AI coding tool.

What it means

Postgres checks access in two layers. Grants decide whether a role, like Supabase’s anon (signed-out visitors) or authenticated (signed-in users), can touch a table at all. Row-level security (RLS) then decides which rows it can read or change. This error comes from the first layer: the role has no grant for this action on this table.

Both layers report code 42501, so read the words. “Permission denied” means a missing grant; “violates row-level security policy,” or an empty result, means RLS. Supabase’s API answers this one with status 401 for signed-out requests and 403 for signed-in ones.

Supabase used to grant anon, authenticated, and service_role access to every new table in public automatically. Projects created since May 30, 2026 don’t, and the change reaches existing projects on October 30, 2026. Tables created after that need explicit grants before supabase-js can reach them. Existing tables keep their grants.

Common causes

  • The table was created after Supabase stopped granting new tables automatically, and no grant statement was run for it.
  • A migration tool or ORM created the table as a different database role, so Supabase’s default grants didn’t apply.
  • The table lives in a custom schema, which never gets the default grants.
  • A script ran revoke on the table, or granted only select while the app also writes.

How to fix it

  1. Note which role made the request: anon without a session, authenticated with one, and service_role with the secret key.
  2. Grant only the actions the app needs, such as grant select, insert, update, delete on public.todos to authenticated;. Grant select to anon only if signed-out visitors should read the table.
  3. Make sure RLS is on and the table has policies before you grant anything to anon or authenticated.
  4. For a custom schema, also run grant usage on schema app to authenticated; and add the schema to the exposed schemas in the Data API settings.
  5. Put the grant statements in the same migration that creates the table, so every environment gets them.

Example fix

-- Grants decide who can reach the table; RLS still decides which rows.
grant select, insert, update, delete on public.todos to authenticated;
grant select, insert, update, delete on public.todos to service_role;
-- Only if signed-out visitors should read it:
grant select on public.todos to anon;

alter table public.todos enable row level security;

create policy "Read own todos" on public.todos
for select to authenticated
using ((select auth.uid()) = user_id);
All errors
new row violates row-level security policy for table "todos"

New row violates row-level security policy

Postgres refused to save a row because no row-level security policy on that table lets the current user write it.

GET https://api.example.com/items 401 (Unauthorized)

401 Unauthorized

The server couldn’t confirm who was calling: the request had no credentials, or its API key, session, or token was wrong or expired.

GET https://api.example.com/items 403 (Forbidden)

403 Forbidden

The server knows who is calling and still refuses, because that user, key, or visitor lacks permission for this request.

relation "todos" does not exist

Relation does not exist

The query names a table that Postgres can’t find in this database, usually because it was never created here or has a different name.

Build with an agent that checks its work

The Layerwise agent clicks through the live preview and reads the console, so it catches errors before you do.