Supabase database
Supabase views and security_invoker: stop views bypassing RLS
A Postgres view runs with the rights of the role that owns it, not the person querying it, so in Supabase a view over an RLS-protected table usually returns every row to anyone allowed to read the view. Setting security_invoker = true on the view (Postgres 15 and later) makes it run as the caller, so the table’s policies apply again.
TL;DR
- Views created by migrations are owned by
postgres, which owns the tables and isn’t subject to their RLS. The view sees everything. - Views in
publicare served by the API like tables, so/rest/v1/your_viewcan leak a table you locked. - Fix:
alter view public.x set (security_invoker = true);and revoke the view fromanonif visitors don’t need it. - Materialized views can’t use RLS or security_invoker: keep them away from the API roles.
The problem in one example
You did everything right on the table: RLS on, an owner-only read policy. Then a dashboard needed orders joined to customer names, so a view appeared:
create policy "Users read own orders" on public.orders
for select to authenticated
using ((select auth.uid()) = user_id);
create view public.order_summaries as
select o.id, o.total, o.created_at, p.full_name, p.email
from public.orders o
join public.profiles p on p.id = o.user_id;
grant select on public.order_summaries to anon, authenticated;A signed-in user querying orders sees their own orders. The same user, or a logged-out visitor, querying order_summaries sees every order with every customer’s name and email.
Why views skip row level security
By default a Postgres view executes with the privileges of its owner. When it reads orders, Postgres checks the owner’s permissions, and applies row level security as it would for the owner. In Supabase, migrations and the SQL editor run as postgres, so postgres owns both the tables and the view, and a table’s owner is not subject to that table’s RLS unless the table is set to force row level security. So the view’s query sees every row.
The API then serves the view like a table. Anything in the public schema that anon or authenticated can select from is reachable at https://<project>.supabase.co/rest/v1/<name> with your public key.
Find views that run as their owner
select c.relname as view_name,
case c.relkind when 'm' then 'materialized view' else 'view' end as kind,
has_table_privilege('anon', c.oid, 'select') as anon_can_read,
has_table_privilege('authenticated', c.oid, 'select') as signed_in_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')
and not coalesce(array_to_string(c.reloptions, ',')
~* 'security_invoker=(true|on|1|yes)', false);Every row is a view that ignores your policies. The two right-hand columns tell you who can reach it. Supabase’s Security Advisor reports the same thing as lint 0010 security_definer_view.
Then confirm what a visitor actually gets, inside a transaction that changes nothing:
begin;
set local role anon;
select count(*) from public.order_summaries;
rollback;The fix: security_invoker
alter view public.order_summaries set (security_invoker = true);
revoke select on public.order_summaries from anon;Now the view runs as whoever queries it. A signed-in user gets only rows that pass the policies on orders and profiles, so they see their own orders joined to their own profile. Visitors get nothing, because they can’t read the view at all.
For new views, write it in from the start:
create view public.order_summaries
with (security_invoker = true) as
select o.id, o.total, o.created_at, p.full_name
from public.orders o
join public.profiles p on p.id = o.user_id;What changes for your app
- The caller now needs
selectpermission on the underlying tables. Supabase’s default grants usually cover this; if a view starts returning “permission denied”, grant the table toauthenticatedand let the policies do the filtering. - An admin dashboard that relied on the view seeing everything will now see only what the admin’s own policies allow. Give admins an explicit policy on the tables (for example with a
has_role('admin')helper), rather than reverting the view. security_invokerneeds Postgres 15 or later. Current Supabase projects run 15 or newer; check withselect version();.
Materialized views
A materialized view stores its results. Postgres doesn’t support row level security or security_invoker on it, so whoever can select from it sees every stored row. Keep it away from the API roles:
revoke select on public.daily_stats from anon, authenticated;
-- Then either move it to a schema the API doesn't expose,
-- or serve it through a function that filters by (select auth.uid()).Supabase’s linter flags these as 0016 materialized_view_in_api.
When an owner-rights view is deliberate
Sometimes a view running as its owner is exactly the point: you want visitors to see a safe summary of a private table, such as the number of listings per city, or the display names of sellers without their emails. That is a legitimate pattern when the view selects only columns meant for everyone. Leave a comment in the migration saying so, because the linter will keep flagging it, and the next person (or the next AI edit) needs to know it was a decision.
Also check for views that expose auth.users, which holds every user’s email: Supabase’s linter reports those as 0002 auth_users_exposed. Copy the columns you need into a profiles table with its own policies instead.
Sources
- PostgreSQL: CREATE VIEW (security_invoker)
- PostgreSQL: Row Security Policies (table owners)
- Supabase: database advisors
Related findings
The checks behind this guide, each with example code, a self-check and the fix.
Free check
Check your app for this, free.
Paste your public repo. PatchLabs rebuilds your database from its migrations, tests it as a visitor, a stranger and the owner, reads the code for keys and risky patterns, and hands you the fix.
Written by Manraj Singh Dhillon. Last checked 10 Nov 2026. Code samples are synthetic and run against your own project; nothing here names a real app. Spotted a mistake? Tell us.