Supabase Security Definer View: an RLS bypass
A Postgres view runs with the privileges of whoever created it, not whoever queries it. So a view in the public schema over an RLS-protected table hands back every row — RLS never runs. Fix it by recreating or altering the view with security_invoker = on, which makes it execute as the calling user and re-applies your policies.
This is what Supabase's database linter means by security_definer_view (lint 0010), and it's one of the easier ways to undo a set of otherwise-correct RLS policies without touching them at all.
Why does a view bypass RLS?
Because of who the query runs as. Row Level Security is evaluated against the current role, and Postgres table owners are exempt from RLS on their own tables by default. Views are executed with the permissions of the view's owner — in a Supabase project, typically postgres, which also owns your tables.
Chain those two facts together and the leak is obvious:
anonqueries the view over the Data API.- The view executes as its owner,
postgres. postgresowns the underlying table, so RLS is skipped.- Every row comes back.
Supabase's own lint documentation puts the consequence plainly: views like this "bypass row level security rules and could expose more data publically over the project's APIs than the developer intended."
One naming nuance worth knowing, because it trips people up when they go looking in the Postgres docs: SECURITY DEFINER is formally a function property. Views don't have that keyword. The lint borrows the name because the default behavior is definer-like — the view runs as its definer. What views actually have is the inverse switch, security_invoker, added in Postgres 15.
What it looks like
Say you have a properly locked-down table:
create table public.orders (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id),
total_cents int not null,
card_last4 text
);
alter table public.orders enable row level security;
create policy "own orders" on public.orders
for select using (auth.uid() = user_id);
That policy is correct — it's scoped to the user, which is exactly what you want. Then someone adds a convenience view for a dashboard:
-- Looks harmless. Returns every user's orders to anyone.
create view public.order_summary as
select id, user_id, total_cents, card_last4
from public.orders;
grant select on public.order_summary to anon, authenticated;
Now GET /rest/v1/order_summary with nothing but the publishable key returns the whole table. The policy on orders is still enabled, still correct, and completely irrelevant — the view walked around it. This is the same category of failure as an ORM connecting as the postgres role: the policies are fine, but the query never runs as a role they apply to.
How do I fix a security definer view?
Set security_invoker = on. For an existing view, no recreate needed:
alter view public.order_summary set (security_invoker = on);
Or define it that way from the start:
create view public.order_summary
with (security_invoker = on)
as select id, user_id, total_cents, card_last4
from public.orders;
With security_invoker = on, the view executes as the caller. anon hits the view, Postgres evaluates orders' policies against anon, auth.uid() is NULL, and the query returns nothing — which is the correct answer. An authenticated user gets exactly their own rows.
Two things to check after flipping it:
- The caller needs
SELECTon the underlying tables too. Under invoker semantics, permissions are no longer inherited from the view owner. Ifanon/authenticatedwere only ever granted on the view, grant them on the base table as well — otherwise you'll trade a data leak for apermission deniederror, which is a different problem with its own fix. - Postgres 15 or newer is required. Every current Supabase project qualifies; a very old self-hosted instance may not.
Don't fix it by dropping the view and forgetting the pattern
Two related traps live next door to this one.
Views over auth.users. Exposing a view that selects from auth.users leaks email addresses and provider identities to the API. Don't surface auth.users through a public view at all — copy the fields you need into a profiles table with its own policies.
FORCE ROW LEVEL SECURITY for real defense in depth. Table owners bypass RLS by default, which is the root of this whole issue. You can turn that off:
alter table public.orders force row level security;
Now policies apply to the owner too, so an owner-run view can't silently return everything. Be deliberate here — it also means your own migrations and admin scripts running as postgres are subject to policies. Test it before shipping.
While you're in this area, the sibling lint on functions is worth a pass too: a SECURITY DEFINER function without a pinned search_path is a genuine privilege-escalation path, not just a warning.
How to find every affected view
Query the catalog directly — this lists views in public that are not invoker-scoped:
select c.relname as view_name,
pg_get_userbyid(c.relowner) as owner
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where c.relkind in ('v', 'm')
and n.nspname = 'public'
and coalesce(
(select option_value
from pg_options_to_table(c.reloptions)
where option_name = 'security_invoker'),
'false'
) <> 'true';
Anything returned is reachable through the Data API as its owner. Cross-check it against the Security Advisor in your dashboard, which surfaces the same finding as security_definer_view.
Worth being precise about tooling here: this one is a live-database check. GuardLayer is a static scanner — it reads your source and SQL migrations, so it catches an un-scoped view if the create view is in a migration file, but it cannot see a view somebody created by hand in the SQL editor. For that, the catalog query above and the dashboard advisor are the authoritative answers. GuardLayer's RLS rules cover the adjacent migration-level mistakes: tables created without RLS, USING (true) policies, and tables granted to anon with no policy behind them.
FAQ
Does security_invoker = on break my existing view?
It can, but in a visible way: queries start returning fewer rows, or a permission denied if the caller lacks SELECT on the base tables. Both are correct behavior finally showing up. Grant the base-table permissions and verify the policies return what you expect.
Is the Supabase linter warning safe to ignore? Only if the view exposes genuinely public data. If the underlying table has RLS policies at all, the warning means those policies aren't protecting anything reached through this view.
Do materialized views have the same problem?
Materialized views don't support security_invoker and RLS isn't applied to them on read. Keep them out of the exposed schema, or revoke API access and query them from server-side code holding a secret key.
Why do views default to this behavior? It predates RLS and is standard Postgres: views were designed as an encapsulation tool where the owner grants controlled access to data the caller can't read directly. That's useful — it's just the opposite of what you want for a view sitting in an internet-facing schema.
Does this affect RPC functions too?
Functions have the real SECURITY DEFINER keyword and the same risk profile. If you use one, pin its search_path and check the caller's identity inside the function body.
Catch this before it ships — free
GuardLayer scans every push for this and 24 other Next.js + Supabase issues, with the exact fix inline.
No signup, no card — your code is scanned in memory and never stored.