LatchDog

← Guides

Supabase views bypass RLS: how to find and fix security definer views

Short answer: in Postgres a view checks permissions as its owner, not as the person querying it. In Supabase the owner is usually postgres, which bypasses Row Level Security, so a view in the public schema can return every row of a table whose own RLS policies are perfect. Fix it with alter view public.your_view set (security_invoker = true); (Postgres 15+), or take the view away from the API roles with revoke, or move it to a schema the Data API doesn't expose. Then test from the outside with your anon key.

Why a view skips RLS by default

The Postgres docs put it plainly: by default, access to the tables a view reads "is determined by the permissions of the view owner", and if those tables have RLS, "the row-level security policies of the view owner are applied" (PostgreSQL: CREATE VIEW). Views you create in the Supabase SQL editor, in migrations, or through an AI tool are usually owned by postgres, and postgres bypasses RLS. Supabase's own docs: "Views bypass RLS by default because they are usually created with the postgres user" (Supabase: Row Level Security).

So this common pattern leaks:

-- profiles has RLS on and an owner-only policy. Good.
-- Then the AI adds a convenience view for a leaderboard or admin page:
create view public.profile_list as
  select id, full_name, email, plan from public.profiles;
-- Anyone with your public anon key can now read every profile through the view.

A view in public is served by the Data API at /rest/v1/profile_list exactly like a table, and supabase-js calls it with supabase.from('profile_list'). Supabase's Security Advisor flags this as lint 0010 security_definer_view ("View bypasses row-level security", level ERROR) (Supabase: Advisors, 0010). If the view reads auth.users, you'll also see lint 0002 (user data exposed through a view).

Two related traps: you can't turn RLS on for a view itself (RLS is a table feature, so "enable RLS on the view" isn't a fix), and materialized views can't respect RLS at all. Supabase lint 0016 says to revoke API access to them instead.

Find the affected views (SQL)

Run this in the Supabase SQL editor. It lists views in public that don't have security_invoker set, and whether the API roles can select from them. It uses the same option check as Supabase's lint.

select
  c.relname as view_name,
  pg_get_userbyid(c.relowner) as owner,
  has_table_privilege('anon', c.oid, 'select') as anon_can_select,
  has_table_privilege('authenticated', c.oid, 'select') as authenticated_can_select
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public'
  and c.relkind = 'v'
  and not (
    lower(coalesce(c.reloptions::text, '{}'))::text[]
    && array['security_invoker=true', 'security_invoker=on', 'security_invoker=1', 'security_invoker=yes']
  )
order by 1;

Any row with anon_can_select = true is readable by anyone with your anon key, with the owner's rights. authenticated_can_select = true means any signed-in user, which on an app with open sign-up is nearly the same thing.

To see the explicit grants on your views (and materialized views, which the query above skips):

select g.table_name, g.grantee, string_agg(g.privilege_type, ', ') as privileges
from information_schema.role_table_grants g
where g.table_schema = 'public'
  and g.grantee in ('anon', 'authenticated', 'PUBLIC')
  and g.table_name in (
    select relname from pg_class c join pg_namespace n on n.oid = c.relnamespace
    where n.nspname = 'public' and c.relkind in ('v', 'm')
  )
group by 1, 2
order by 1, 2;

Check your Postgres version with select version();. security_invoker needs Postgres 15 or later. On older projects, use the revoke or schema fixes below.

Fix 1: make the view respect RLS (security_invoker)

If the view should show each user only what they could already read from the underlying tables:

alter view public.profile_list set (security_invoker = true);
-- or, when you create it:
create view public.profile_list with (security_invoker = true) as
  select id, full_name from public.profiles;

With security_invoker on, Postgres checks the caller's privileges and RLS policies on every table the view reads, "as if the base relations had been referenced directly". That has a consequence: the view is only as safe as its tables. Each underlying table needs RLS enabled, a policy for the role that reads it, and a select grant to that role. If the grant is missing the caller gets error 42501 (permission denied). That fails closed, which is safe, but your page breaks until you add the narrowest grant it needs. From October 30, 2026, new tables in public on existing Supabase projects no longer get API grants automatically (Supabase changelog). Postgres default privileges for tables also cover views, so expect new views to need explicit grants too, and add them on purpose rather than with grant all.

Fix 2: take the view away from the API roles (revoke)

If the browser never needs the view (admin reports, analytics, internal joins):

revoke all on public.profile_list from anon, authenticated, public;
-- confirm (should return false):
select has_table_privilege('anon', 'public.profile_list', 'select');

This is also the fix for materialized views. Revoke from anon and authenticated: in default Supabase projects both roles start with the same grants, so revoking from one alone usually leaves the other open.

Fix 3: move it out of the exposed schema

create schema if not exists private;
alter view public.profile_list set schema private;

The Data API only serves schemas listed under Exposed schemas (normally public), so a view in private isn't reachable from the browser. Any client code that called .from('profile_list') will stop working; replace it with a server-side function or an Edge Function that checks who is asking.

When a definer view is intentional

Some apps use a definer view on purpose to publish a few harmless columns, such as available booking dates without who booked them. That can be fine if every column and every row it returns is truly public. Keep it to those columns, never select from auth.users, and expect Security Advisor to keep flagging it. A grep-able comment in the migration saying why saves the next person (or AI) from "fixing" it into a leak.

Paste-in fix prompt for Lovable, Bolt or Cursor

My Supabase project has views in the public schema that run with the owner's
privileges (security definer), so they bypass Row Level Security and can expose
rows to the anon key. Please:
1. List every view and materialized view in the public schema, and for each one
   say which tables it reads and whether anon or authenticated can select from it.
2. For views the app reads from the browser and that should follow RLS: write
   "alter view public.NAME set (security_invoker = true);" and make sure every
   table the view reads has RLS enabled, a policy for each role that reads it,
   and only the select grant that role needs.
3. For views (and all materialized views) the browser doesn't need: write
   "revoke all on public.NAME from anon, authenticated, public;" or move them to
   a private schema and replace client calls with a server-side function.
4. Do not disable RLS, do not grant all privileges to anon, and do not put the
   service_role or sb_secret_ key in client code to make anything work.
Show me the SQL migration first and explain which role can read what afterwards.
Do not apply it until I approve.

Review what the AI changes before you publish, then rerun the SQL above and the outside-in test below.

Test it from the outside with your anon key

Only test your own project. Use the anon or publishable key your site already ships (not a secret key):

curl "https://YOUR-PROJECT-REF.supabase.co/rest/v1/profile_list?select=*&limit=1"   -H "apikey: YOUR_ANON_OR_PUBLISHABLE_KEY"

Don't rely on view names being secret. Since April 2026 Supabase no longer serves the API's schema listing to the anon key (Supabase changelog), but names are easy to guess, your own JavaScript bundle contains every .from('...') call, and if pg_graphql is enabled its introspection lists every relation anon can select (Supabase lint 0026).

What LatchDog does here (and doesn't)

Check your app now

Free, read-only public check: no sign-up, no card. Headers and HTTPS only. Deep scan (leaked keys, open Supabase tables, .env/.git) after you verify you own the domain.