PostgreSQL · 42501
permission denied for table — GRANT, not RLS (Supabase)
Luca Urti
permission denied for table
What is the difference between “permission denied for table” and an RLS error?
They come from two different layers, and confusing them costs hours. “permission denied for table” means the role making the request — anon or authenticated — has no GRANT on that table at all, so PostgreSQL refuses before Row Level Security is ever consulted. RLS errors, by contrast, only occur after the grant has been checked and passed. Enabling RLS, writing policies or rewriting a using expression will therefore do nothing for this message. What you need is a grant, or — much more often in Supabase — the table is not in a schema that is exposed through the API at all.
The layers, in the order Postgres applies them
First the schema must be exposed to PostgREST — Supabase exposes public by default, and nothing else until you add it. Then the role needs a GRANT on the table. Only then does RLS filter rows. A request fails at the first gate that stops it, so an unhelpful mental model of RLS as “the security” hides the fact that two gates sit in front of it.
Supabase grants anon and authenticated full DML on tables created through the dashboard, which is why most people never see this error — until they create a table via a migration written by hand or by an AI tool that emitted a plain create table with no grants, or put it in a schema of its own.
A missing grant is not a security feature
It is tempting to read “permission denied” as “this table is protected”. It is not. The grant is coarse — it is per table and per role, not per row — and the moment you add the grant so your app works, every anonymous visitor can read the whole table unless RLS is also enabled with a policy. A table that is unreachable today because of a missing grant and has no RLS is one grant statement away from being public, and that statement is usually the next thing that gets run.
This is why fixing this error is a two-statement job, never one. Grant the access the app needs, and enable RLS in the same migration so the table is never briefly reachable and unprotected.
The fix
grant and protect in one migration
-- Both statements belong in the same migration. A grant without RLS makes the -- table world-readable to anyone holding the anon key, which is everyone. grant select, insert, update, delete on public.invoices to authenticated; alter table public.invoices enable row level security; create policy "own invoices" on public.invoices for all to authenticated using (auth.uid() = user_id) with check (auth.uid() = user_id);
The fix that works and costs you the database
The shortcut is grant all on all tables in schema public to anon, authenticated;. It clears the error across the whole database in one line, and it hands every present and future table to anonymous callers with only RLS standing between them and the data — on tables you have not written policies for yet, and on the ones a migration adds next month. Grant the table you need, not the schema.
Whether the trap is already in your repo is a question you can answer
Sentris reads the SQL and the client code, so it reports the shortcut above where it was actually taken — a service_role key in a browser bundle, a policy that is using (true), a table with RLS switched off. A scan needs no account and no card. How often it is wrong is measured and published.