Fix: permission denied for view (Supabase 42501)
ERROR: 42501: permission denied for view order_summaries means the role making the request, anon or authenticated, has no SELECT grant on the view itself. Since October 30, 2026, new views in public get no automatic Data API grants, just like new tables, so every view you create needs an explicit one. Before you grant it, run alter view ... set (security_invoker = on). Without that, the view reads its tables with its owner's rights, skips their RLS, and your grant gives every caller every row.
So the error isn't the dangerous part. The dangerous part is the one-line fix that makes it go away.
Postgres checks three things for a view
When supabase.from("order_summaries").select() runs, Postgres asks:
- Can the caller
SELECTfrom the view? No →permission denied for view order_summaries. - Can the view read its tables? By default it checks the view owner's privileges here, not the caller's. With
security_invoker = onit checks the caller's. - Which rows do the tables' RLS policies allow? By default, the policies are evaluated for the owner. In Supabase that's usually
postgres, which bypasses RLS, so every row comes back. Withsecurity_invoker = onthey're evaluated for the caller.
Your error is gate 1. Gates 2 and 3 decide whether fixing it is safe. The Security Definer View warning in Supabase's Security Advisor is about gates 2 and 3.
Why it started now
Supabase's Data API change reached every existing project on October 30, 2026. New objects in public no longer get automatic select, insert, update and delete grants for anon, authenticated and service_role. Postgres default privileges for tables also cover views, so a view you create today is unreachable through the API until you grant it.
Views that already existed keep their grants. If an old view suddenly returns this error, something revoked its grant or recreated it. A drop view plus create view gives you a new object with none of the old grants.
Before the change, a new view was readable by anon the moment it existed. If it wasn't security_invoker, it returned every row to anyone with your anon key. The error you're seeing is the new default stopping exactly that.
The fix that leaks every row
Here's the fix most people reach for, in a real migration, and what a scan says about it:
- Warningsupabase/migrations/20261102100000_order_summaries_view.sql:8
Table or view granted to the anon role without RLS
For a table, enable RLS (ALTER TABLE <t> ENABLE ROW LEVEL SECURITY;) and add scoped policies. For a view, create it WITH (security_invoker = true) and repeat that clause on every CREATE OR REPLACE VIEW. Materialized views and foreign tables cannot be protected: revoke the anon grant or move them to a schema the Data API does not expose. Prefer per-table grants over ON ALL TABLES IN SCHEMA public. Only keep an anon grant for genuinely public data that is still RLS-protected.
The view joins orders and profiles and runs as its owner, postgres. Both tables can have perfect RLS and it won't matter: the view reads them as postgres, and the grant hands the result to anon. Anyone with your anon key, which ships in your JavaScript bundle, can now page through every order and every customer's email:
curl "https://<project>.supabase.co/rest/v1/order_summaries?select=*" \
-H "apikey: <anon key>"
The safe fix
Make the view apply the caller's RLS first, then grant only the role that needs it:
-- 1. Run as the caller, so the tables' RLS applies (Postgres 15+).
alter view public.order_summaries set (security_invoker = on);
-- 2. Signed-in users only. Skip anon unless the data is truly public.
grant select on public.order_summaries to authenticated;
-- 3. Under invoker rights, the caller also needs the tables underneath.
grant select on public.orders, public.profiles to authenticated;
-- 4. And the tables' RLS decides which rows they get.
alter table public.orders enable row level security;
create policy "users read their own orders"
on public.orders for select
to authenticated
using ((select auth.uid()) = user_id);
alter table public.profiles enable row level security;
create policy "users read their own profile"
on public.profiles for select
to authenticated
using ((select auth.uid()) = id);
Better still, create the view that way in the first place, and repeat the option on every later create or replace view:
create or replace view public.order_summaries
with (security_invoker = on) as
select o.id, o.user_id, o.total, o.status, p.email
from public.orders o
join public.profiles p on p.id = o.user_id;
Which message you get, and what it means
All of these are SQLSTATE 42501. Over the Data API, PostgREST returns them as HTTP 401 for an anonymous request and 403 for a signed-in one.
| Message | What's missing | Fix |
|---|---|---|
permission denied for view v | The caller has no SELECT on v | grant select on v to authenticated (after security_invoker = on) |
permission denied for table t, right after you set security_invoker | The caller has no SELECT on the base table t | grant select on t to authenticated, with RLS on t. More here |
permission denied for view inner, when you queried outer | outer is an invoker view reading another view the caller can't | Grant on inner too, and make it security_invoker as well |
permission denied for table users | An invoker view reads auth.users | Don't grant it. Read the user another way |
permission denied for materialized view mv | The caller has no SELECT on mv | Don't grant it to API roles. See below |
The second row trips people up: you switch on security_invoker, the error changes to a table, and it looks like you broke something. You didn't. The view now checks the caller's rights on the table, which is the point. Grant the table to authenticated, keep RLS on it, and you're done.
Materialized views
A materialized view stores its rows. It has no security_invoker option and can't have RLS, so whoever can SELECT from it sees everything in it. permission denied for materialized view is the safe state. Keep it that way for anything per-user: move the materialized view to a schema the Data API doesn't expose, and serve it through a SECURITY DEFINER function with set search_path = '' that returns only the caller's rows (where user_id = (select auth.uid())). An invoker view on top wouldn't help: the caller would need SELECT on the materialized view itself, which hands them all of it.
Quick self-check
This lists every view in public, whether it runs as the caller, and who can read it. It only reads the catalog.
select c.relname as view,
case c.relkind when 'm' then 'materialized' else 'view' end as kind,
exists (
select 1 from unnest(coalesce(c.reloptions, '{}'::text[])) o
where lower(o) in ('security_invoker=true', 'security_invoker=on',
'security_invoker=1', 'security_invoker=yes')
) as security_invoker,
has_table_privilege('anon', c.oid, 'select') as anon_can_read,
has_table_privilege('authenticated', c.oid, 'select') as authenticated_can_read
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public'
and c.relkind in ('v', 'm')
order by anon_can_read desc, security_invoker;
A row with security_invoker = false and either _can_read column true is a view that hands out rows its tables' policies would hide. A view with both columns false will return permission denied for view to your app, which is safe but probably not what you meant.
Live check runs the same check on your project, along with the table grants and RLS gaps, and gives you the SQL fix for each finding. It's read-only.
FAQ
Why do I get "permission denied for view" on a view I just created?
Since October 30, 2026, new views in public get no automatic grants for anon, authenticated or service_role. Set security_invoker = on, then grant select on public.your_view to authenticated.
Should I grant the view to anon?
Only if every row it returns is public. If the view isn't security_invoker, the grant bypasses the RLS on every table under it.
Does security_invoker work on Supabase?
Yes. It needs Postgres 15 or later. Check your version with select version();.
Does service_role need a grant on the view too?
Yes, for views created after the change. service_role bypasses RLS but still needs table and view privileges: grant select on public.your_view to service_role.
I set security_invoker and now get "permission denied for table". Did I break it?
No. The view now checks the caller's privileges on its base tables. Grant select on those tables to authenticated and keep RLS enabled on them.
See which of your Supabase tables anyone can read, free
Connect your Supabase project read-only. GuardLayer checks RLS, policies, grants and functions as they are right now, and gives you the SQL to fix each problem. It never reads your rows and can't change anything.
Check my Supabase projectPrefer to check code instead? Scan your repo, no signup.