Supabase view bypasses RLS: anyone can read or delete rows through it
A Postgres view runs with its owner's rights by default, so a view over a table with RLS can hand every row to anyone with your anon key. How to find risky views in a Lovable, Bolt, or Cursor app and lock them down with security_invoker.
What you are seeing
- Your RLS policies look right and queries on the table return only the user's own rows, but a view built on that table returns everyone's rows.
- Supabase's Security Advisor shows the warning
security_definer_view("Security Definer View") for one or more views inpublic. - A logged-out request with only the public anon key returns data from a view, for example
/rest/v1/profiles_view?select=*. - Your AI tool added a view to "simplify" a query or join two tables, and that is the only part of the app that leaks.
Why it happens
- In Postgres, a view checks permissions as the role that owns the view, not the role querying it. Views created in the SQL editor or by migrations are usually owned by
postgres, which bypasses RLS, so the view reads every row. - Views in the
publicschema are exposed through the Data API like tables. Ifanonorauthenticatedhas privileges on the view, anyone holding the anon key (which ships in your frontend) can query it. - Supabase historically granted
anonandauthenticatedbroad privileges on objects inpublicby default, so a new view was often reachable without anyone granting it on purpose. - Simple views can also be written through. If the roles have INSERT, UPDATE, or DELETE on an auto-updatable view, a request through the view can change or delete the underlying rows, skipping RLS the same way reads do.
How to fix it
1. Find every view in public and who can use it
select c.relname as view_name,
coalesce(array_to_string(c.reloptions, ','), '') as options,
has_table_privilege('anon', c.oid, 'select') as anon_select,
has_table_privilege('anon', c.oid, 'delete') as anon_delete,
has_table_privilege('authenticated', c.oid, 'select') as auth_select
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 1;
2. Make each view respect the caller's RLS (Postgres 15+)
alter view public.your_view set (security_invoker = true);
-- when you create new views, set it up front:
create view public.your_view
with (security_invoker = true) as
select ...;
3. Revoke what the API roles should not have
-- most views only need reads, for signed-in users only
revoke all on public.your_view from anon;
revoke insert, update, delete on public.your_view from authenticated;
grant select on public.your_view to authenticated;
4. Handle materialized views separately
Materialized views (relkind m) do not support RLS or security_invoker at all. Move them to a schema that is not exposed through the API, or revoke access from anon and authenticated and read them only from server code.
5. Test as an outsider
curl 'https://YOUR_PROJECT.supabase.co/rest/v1/your_view?select=*' \
-H 'apikey: YOUR_ANON_KEY'
# expect [] or a permission error, never real rows
6. Tell your AI tool the rule
Add this to your project rules (.cursorrules, Lovable knowledge, or Bolt prompt): "Every view in public must be created WITH (security_invoker = true), and grant only SELECT to authenticated unless logged-out access is required. Never grant insert, update, or delete on views to anon."
How to know it is fixed
Re-run the query from step 1: every view shows security_invoker=true in options, and anon_select and anon_delete are false unless you meant the view to be public. The Security Advisor no longer lists security_definer_view, the curl test with the anon key returns no real rows, and a signed-in user sees only their own rows through the view. Sources: PostgreSQL CREATE VIEW and Supabase lint 0010.