Supabase "Security Definer View" warning
You enabled Row Level Security on every table, wrote careful policies, and the Supabase Security Advisor still shows an error: Security Definer View (lint 0010_security_definer_view). This one surprises a lot of people, because the data is in a table you already protected. The problem is that a view can quietly bypass those protections.
This guide explains why, how to find the affected views, and how to fix them, usually with a single statement.
Why views can bypass RLS
In Postgres, a view is a saved query. By default, when someone queries a view, Postgres checks permissions on the underlying tables as the owner of the view, not as the person running the query. That behaviour is called security definer.
On Supabase, views created in the SQL Editor or by migrations are usually owned by the postgres role. The postgres role is highly privileged and, as the owner of those tables, is not subject to their RLS policies. So when an anonymous visitor queries your view through the API, the underlying tables are read as postgres, your RLS policies are skipped, and every row the view selects comes back.
Supabase exposes views in the public schema through the same REST API as tables. If the view is in public and anon or authenticated has select on it, which is the default, anyone with your public key can query it:
curl "https://YOUR-PROJECT.supabase.co/rest/v1/user_stats?select=*" \
-H "apikey: YOUR_PUBLISHABLE_OR_ANON_KEY"AI coding tools create views often, for dashboards, leaderboards, "profiles with post counts", and reporting, which is why this warning shows up in so many generated projects.
Step 1: Find the affected views
The Security Advisor lists them. To see every view in your exposed schemas and whether it runs as invoker, run:
select n.nspname as schema,
c.relname as view_name,
coalesce(c.reloptions::text, '') as options,
pg_get_userbyid(c.relowner) as owner
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where c.relkind = 'v'
and n.nspname = 'public'
order by c.relname;Views whose options do not include security_invoker=true run as their owner. Then check who can read them:
select table_name, grantee, privilege_type
from information_schema.role_table_grants
where table_schema = 'public'
and grantee in ('anon', 'authenticated')
and table_name in (select viewname from pg_views where schemaname = 'public')
order by table_name, grantee;Step 2: Switch the view to security invoker
Since Postgres 15, which Supabase uses, you can tell a view to check permissions as the calling user instead of the owner. With security_invoker on, RLS policies on the underlying tables apply to whoever queries the view.
For an existing view:
alter view public.user_stats set (security_invoker = true);When creating a new view:
create view public.user_stats
with (security_invoker = true)
as
select p.id, p.display_name, count(n.id) as note_count
from public.profiles p
left join public.notes n on n.user_id = p.id
group by p.id, p.display_name;Supabase Studio can also apply this fix from the advisor panel. Either way, re-run the advisor afterwards to confirm the warning is gone.
Step 3: Check that your app still works
After switching to security invoker, the view returns only what the caller's RLS policies allow on each underlying table. That is the point, but it can change results:
- A leaderboard that showed everyone's scores will now show only rows the caller is allowed to read. If you really want a public leaderboard, add a deliberate read policy on the underlying data, or build a table with only the public fields.
- A view that joins a table where the caller has no
selectgrant now fails with a permission error instead of returning data.
Ask what the view is meant to show and to whom, then make the underlying policies say that explicitly. Do not "fix" a broken view by loosening table policies to using (true).
Alternative: keep the view out of the API
If the view is only used by server code, such as a reporting job or an admin Edge Function using the secret key, it does not need to be reachable from the browser at all. Two options:
-- Option A: remove API access for client roles
revoke all on public.user_stats from anon, authenticated;
-- Option B: move it to a schema that is not exposed through the API
create schema if not exists private;
alter view public.user_stats set schema private;Server code with the secret key can still query it (for schemas other than public, use a direct database connection or expose the schema only to the service role).
The same problem with functions
Functions have the same two modes. A function declared security definer runs with its owner's privileges, which often means bypassing RLS. Supabase exposes functions in public as RPC endpoints (/rest/v1/rpc/function_name), and by default Postgres lets any role execute a new function.
Security definer functions are legitimate and sometimes necessary, for example the helper functions in our RLS policy examples or a trigger that creates a profile on signup. But each one should:
- Do only one narrow thing, and check
auth.uid()itself if it acts on behalf of a user. - Set a fixed search path, which Supabase's advisor checks for (lint
0011_function_search_path_mutable). - Not be callable by roles that do not need it.
create or replace function public.handle_new_user()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
begin
insert into public.profiles (id) values (new.id);
return new;
end;
$$;
-- Trigger functions do not need to be callable over the API
revoke execute on function public.handle_new_user() from public, anon, authenticated;To list all security definer functions in public:
select p.proname, pg_get_function_identity_arguments(p.oid) as args, p.proconfig
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.prosecdef
order by p.proname;Review each one. If it can be called via RPC and returns data from protected tables without checking the caller, it is an open door just like a security definer view.
A prompt for your AI tool
List every view and every security definer function in the public schema. For each view, set security_invoker = true unless it is only used by server code, in which case revoke access from anon and authenticated. For each security definer function, add "set search_path = ''", fully qualify table names, check auth.uid() where it acts for a user, and revoke execute from anon and authenticated if the browser does not call it. Do not loosen any table RLS policy to make a view work.Frequently asked questions
Is a security definer view always a vulnerability?
Not always. If the view only exposes data that is meant to be public, or nobody but the server can query it, the risk is low. But the default is easy to get wrong, so Supabase flags it as an error and it is worth fixing every case deliberately.
Does security_invoker work on Supabase?
Yes. It requires Postgres 15 or newer, which current Supabase projects use.
What about materialized views?
Materialized views do not support RLS or security_invoker. Supabase has a separate lint for materialized views exposed through the API. Keep them out of exposed schemas, or revoke access from anon and authenticated.
How can I confirm what the public can see through my views?
Query the view with your public key from outside the dashboard, as in the curl example above, or run RowLocked's free scan, which checks what anonymous requests can read from your app's Supabase endpoints.