Guides / Security Definer View

last checked oct 2026

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:

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:

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."

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.