Guides / Security Definer View
Supabase "Security Definer View" error: what it means and how to fix it
The Security Advisor shows a red Security Definer View error on a view like user_stats or orders_with_profiles, usually one your AI tool wrote to save a join. This one is worth fixing today: the view can hand out rows your RLS policies were written to hide. The fix is one line, but it can change what the view returns, so test it right after.
The short answer
The error (lint 0010_security_definer_view, level ERROR) means a view in a schema your API exposes runs with its owner's permissions instead of the caller's. On Supabase the owner is usually postgres, which bypasses RLS, so anyone with your public key may be able to read every row of the tables underneath. To fix it:
- Set
security_invoker = trueon the view:alter view public.your_view set (security_invoker = true);. The view then applies the caller's permissions and RLS policies, as if they queried the tables directly. - Or take it off the API if the view is only for you: revoke
selectfromanonandauthenticated, or move it to a schema the API doesn't expose. - Test it as an anonymous and a signed-in user afterwards. If the view suddenly returns nothing, the tables underneath need policies, which is RLS doing its job.
Why views behave like this. Supabase's RLS docs say it directly: "Views bypass RLS by default because they are usually created with the postgres user." Postgres checks a normal view's base tables against the view owner's privileges and applies the owner's RLS policies. The security_invoker option, added in Postgres 15, switches that to the user running the query. A view you create in the SQL Editor is owned by postgres, and postgres has bypassrls on Supabase.
What the advisor actually flags
Supabase's linter only raises this on views that are reachable through the API. Reading its source, a view gets flagged when all of these are true:
- it's a regular view (materialized views have their own lint, below),
anonorauthenticatedhasSELECTon it,- it lives in a schema the Data API exposes (
publicby default), - the database is Postgres 15 or newer, and the view doesn't have
security_invokerturned on.
So a flagged view is one the browser can already query at /rest/v1/your_view. If it joins a table with RLS, the policies on that table don't protect what the view returns.
How to check
1. List your API-visible views
Run this in the SQL Editor. It reads only the system catalog and shows each view and materialized view in public, whether it's set to invoker, and whether the API roles can read it:
select
n.nspname as schema,
c.relname as view,
case c.relkind when 'v' then 'view' else 'materialized view' end as kind,
lower(coalesce(c.reloptions::text, '{}'))::text[]
&& array['security_invoker=on', 'security_invoker=true',
'security_invoker=yes', 'security_invoker=1'] as security_invoker,
pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') as anon_can_read,
pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') as authenticated_can_read
from pg_catalog.pg_class c
join pg_catalog.pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' -- add any other exposed schemas
and c.relkind in ('v', 'm')
order by security_invoker, c.relname;
Any row with security_invoker false and either read column true is what the advisor is pointing at. To see what a view selects, run select pg_catalog.pg_get_viewdef('public.your_view'::regclass, true);
2. See what an anonymous visitor gets
From your project folder, load the URL and the public key into variables without printing them:
export SB_URL="$(grep -E '^(NEXT_PUBLIC_|VITE_)?SUPABASE_URL=' .env | head -1 | cut -d= -f2- | tr -d '"')" export SB_KEY="$(grep -E '^(NEXT_PUBLIC_|VITE_)?SUPABASE_(PUBLISHABLE|ANON)_KEY=' .env | head -1 | cut -d= -f2- | tr -d '"')" [ -n "$SB_URL" ] && [ -n "$SB_KEY" ] && echo "loaded" || echo "not found"
Then ask the API how many rows the view returns to someone who isn't logged in. This prints only the status line and the row count, never the data or the key:
curl -s -I "$SB_URL/rest/v1/your_view?select=*" \ -H "apikey: $SB_KEY" -H "Prefer: count=exact" \ | grep -i -E '^HTTP|^content-range'
The number after the slash is the total. For example, 0-999/4821 would mean an anonymous visitor can page through 4,821 rows, */0 means they get nothing, and a 401 means anon can't read the view at all. (With a legacy anon key, also add -H "Authorization: Bearer $SB_KEY".)
How to fix it
Switch the view to security invoker
For an existing view, one statement does it, and it doesn't change the query:
alter view public.orders_with_profiles set (security_invoker = true);
If you manage the view in a migration, put the option in the definition so it's always there:
create or replace view public.orders_with_profiles with (security_invoker = true) as select o.id, o.total, o.created_at, p.display_name from public.orders o join public.profiles p on p.id = o.user_id;
To generate the alter statement for every flagged view at once, run this. It only prints the statements. Read each view first, then run the ones you've checked:
select format('alter view %I.%I set (security_invoker = true);', n.nspname, c.relname)
from pg_catalog.pg_class c
join pg_catalog.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=on', 'security_invoker=true',
'security_invoker=yes', 'security_invoker=1'])
and (pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')
or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT'));
If the view now returns nothing, or errors
That's expected the first time, because the caller now needs what they'd need to query the tables directly. Postgres's docs spell it out: "the user of a security invoker view must have the relevant permissions on the view and its underlying base relations."
- Empty results: a table underneath has RLS on but no policy that lets this user see those rows. Add the policy to the table, for example
using ((select auth.uid()) = user_id), rather than switching the view back. permission denied for table ...: the API role has noSELECTgrant on a base table, which is common when the view reads from a private schema. Grant only what's needed, and make sure that table has RLS enabled with policies before you do.- The view joins
auth.users: this is the one to stop and rethink. Supabase has a separate ERROR lint (0002_auth_users_exposed) for views that expose user data fromauth.users. Copy the fields your app needs into apublic.profilestable protected by RLS, and point the view at that.
Views that should stay private
Some views are meant for you, like an admin report that counts every order. Don't expose them through the API at all:
revoke select on public.admin_order_stats from anon, authenticated;
Or move it out of an exposed schema, for example into a private schema the Data API doesn't serve, and query it from the dashboard or a server that uses your secret key.
Materialized views are different
A materialized view can't use security_invoker, and it can't be protected by RLS, so Supabase flags API-readable ones separately (lint 0016_materialized_view_in_api). Supabase's fix is to revoke select from anon and authenticated:
revoke select on public.daily_revenue from anon, authenticated;
Confirm it worked
Run the check query again: the views you fixed should show security_invoker true. Re-run the curl count as an anonymous visitor and confirm it only sees what your policies allow. To check a signed-in user's view from the SQL Editor without touching data:
begin;
set local role authenticated;
select pg_catalog.set_config('request.jwt.claims', '{"sub":"a-test-user-uuid","role":"authenticated"}', true);
select count(*) from public.orders_with_profiles; -- should be only that user's rows
rollback;
Then re-run the Security Advisor and click through the pages in your app that use the view.
next step
Check your live app for free
Our free Leak Checker looks at your deployed app the way any visitor's browser does and flags exposed secret keys, public source maps and missing security headers. It can't see inside your database, so it won't catch this error, but it's a quick first pass on what your frontend gives away.
If you want the database side checked too, the $99 Scan + Report covers your repo and live app, including database access rules, leaked keys and deploy settings.
Sources
Every product step above was checked against these official pages. Last checked Oct 2026. Products change; if a setting moved, the docs below are the source of truth.
- Supabase: Advisors, lint 0010_security_definer_view
- Supabase splinter: the SQL behind lint 0010
- Supabase: Row Level Security (Views and RLS, Expose a view safely)
- Supabase: Advisors, lint 0002_auth_users_exposed
- Supabase: Advisors, lint 0016_materialized_view_in_api
- PostgreSQL: CREATE VIEW (security_invoker)
- PostgreSQL: ALTER VIEW
- PostgREST: Exact count with Prefer: count=exact