PL-014high (critical with personal data)coming · not tested yet

View skips row level security

Your table's policies are right, but a view on top of it runs as its owner and hands every row to the API.

Severity
High (critical with personal data)
Who can exploit it
Anyone the view is granted to, often anon
Found by
Not tested yet: coming to PatchLabs Lab
Status
Coming

PatchLabs does not test for this yet (coming). This page explains the problem, how to check your own project and how to fix it. The Lab’s live checks today are PL-001 to PL-008; this one is not part of any report until it ships. Shipping dates go in the changelog.

What it is

A Postgres view is a saved query. By default it runs with the privileges of the view's owner, not the person querying it. In Supabase, views created by migrations or the SQL editor are owned by postgres, which usually owns the tables too, and a table's owner is not subject to that table's row level security (unless the table uses force row level security). So the view returns every row, whatever your policies say.

Views in the public schema are served by the API just like tables. If anon or authenticated can select from the view, one request to /rest/v1/<view name> skips your careful owner-only policy. Postgres 15 added the fix: security_invoker = true makes the view run as the caller, so the table's policies apply again.

Materialized views are stricter still: they store their results and support neither row level security nor security_invoker, so they must be kept away from the API roles entirely.

Why AI builders produce it

Views are how AI builders join tables for a dashboard ("orders with the customer's name") or hide a column. The request is about the shape of the data, and the generated view is correct SQL. Nothing in that SQL says it changes who the query runs as.

Example of vulnerable code

Simplified from the patterns we see in AI-built apps. Synthetic: not taken from any real app.

supabase/migrations/…_views.sql (vulnerable)
-- The table is locked to owners...
create policy "Users read own orders"
  on public.orders for select to authenticated
  using ((select auth.uid()) = user_id);

-- ...but this view runs as its owner and returns every order
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;

Check it yourself

check-pl-014.sql
-- 1. Views in public that run as their owner, and who can read them
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);

-- 2. Read a view as a logged-out visitor (nothing is changed)
begin;
set local role anon;
select count(*) from public.order_summaries;  -- more than 0: visitors read it
rollback;

The fix

Make the view run as the caller, and take it away from roles that never needed it.

supabase/migrations/…_fix_views.sql
alter view public.order_summaries set (security_invoker = true);
revoke select on public.order_summaries from anon;

With security_invoker on, the caller needs read access to the underlying tables and every table's policies apply, so a signed-in user now sees only their own orders, joined to their own profile. For new views, write it in from the start: create view public.x with (security_invoker = true) as …. For a materialized view, revoke select … from anon, authenticated and serve it through a function that filters by auth.uid(), or move it to a schema the API doesn't expose.

PatchLabs doesn’t write this fix yet. Review the SQL with your engineer and add it as a new migration.

How PatchLabs will test it

When this check ships, the Lab will run it the way it runs PL-001 to PL-008 today: rebuild your database from supabase/migrations in a disposable Postgres, seed fake rows for two users, and try the exploit as an anonymous visitor, a signed-in stranger and the owner. Until then, the self-check above is the test.

When the pattern is intended

An owner-rights view can be a deliberate way to publish a safe subset: listing counts per city, or the public columns of profiles. That is fine when the view selects only data meant for everyone, and worth a comment in the migration saying so, because Supabase's linter will keep flagging it.

Why it’s rated high (critical with personal data)

High by default: the view exposes only the columns it selects, to the roles it is granted to. Treat it as critical when it carries personal columns (emails, phone numbers) to anon, because then it is PL-001 by another route.

Supabase linter

0010 security_definer_view
Flags views that run as their owner. The same pattern, from the database side.
0002 auth_users_exposed
Flags views that expose auth.users to the API roles.
0016 materialized_view_in_api
Flags materialized views the API can read.

Guides

Free check

Check what PatchLabs does test today

This check is coming. Today’s free check rebuilds your database from your migrations, tests it as a visitor, a stranger and the owner (PL-001 to PL-008), and reads your repo for keys and risky code.

Free for your first appReads your public repo onlyOr run the free static RLS check

Last reviewed 10 Nov 2026. Finding IDs are stable: link to them freely. Spotted a mistake? Tell us.