Supabase RLS with no policies: is it safe?
Yes, it works — RLS with no policies denies everything, and a SECURITY DEFINER function owned by postgres can then serve data because a table's owner skips RLS. But it moves access control from a per-row check the database enforces to a per-function check you enforce, with nothing underneath it. One function that forgets its auth.uid() guard exposes the whole table, silently.
The architecture in question: turn RLS on for every table, write no policies at all, and do all data access through Postgres functions marked SECURITY DEFINER that check the caller themselves at the top of the body.
It's a reasonable instinct. Policies are hard to read once they involve joins, RLS has real performance costs on large tables, and a function body is plain imperative code you can reason about. People arrive at this design honestly, usually after a long session debugging a policy that returns an empty array.
Is it safe to enable RLS with no policies and use SECURITY DEFINER functions?
It is safe only if you do four things on every single function, without exception — and unsafe the first time you miss one.
Start with what the no-policy half gives you. The Supabase RLS docs are direct about it: once RLS is enabled, no data is accessible through the API with a publishable key until you create policies. That's a genuine deny-by-default. RLS with zero policies isn't a gap; it's a closed door, and it closes the PostgREST path to the table completely. Good.
The SECURITY DEFINER half is where it gets expensive. Such a function runs with the privileges of the role that owns it — on Supabase, usually postgres, which also owns your public tables. A table's owner is exempt from its own RLS policies unless you have set FORCE ROW LEVEL SECURITY, so inside that function body RLS is off by default. Your where clause is the only thing standing between a caller and every row in the table.
Here is what you have taken on, per function:
- An identity check as the first statement.
auth.uid()must be read and acted on before any data is touched. If it isn't there, every caller is effectively an admin. - A pinned
search_path. Withoutset search_path = '', the function resolves unqualified names using the caller'ssearch_path, and anyone who can create objects in a schema on that path can make an elevated function run their table or their function instead. This is the function_search_path_mutable finding, and it applies to every function in this design. - A deliberate
grant execute. Functions are granted toPUBLICby default in Postgres. If you don't revoke,anoncan call it. - No dynamic SQL over user input. Elevated privileges plus string-concatenated SQL is the worst combination available to you in a database.
Miss one of the four and there is no second layer. That's the real trade: in the policy-based design, a bug in your query is still contained by RLS. Here, RLS is switched off for the code path that matters, by design.
The version that ships broken
This is what it looks like when it goes wrong, and it's rarely dramatic:
-- 20260920121000_invoices_rpc.sql
-- RLS is on with no policies; all reads go through this RPC,
-- which does its own permission check.
alter table public.invoices enable row level security;
create or replace function public.get_invoice(invoice_id uuid)
returns setof public.invoices
language sql
security definer
as $$
select * from public.invoices where id = invoice_id;
$$;
grant execute on function public.get_invoice(uuid) to anon, authenticated;
- Warningsupabase/migrations/20260920121000_invoices_rpc.sql:6
SECURITY DEFINER function without a fixed search_path
Pin the search_path on the function: add `set search_path = ''` to the definition (or `ALTER FUNCTION <name> SET search_path = '';`) and fully qualify every object reference inside it, e.g. public.profiles instead of profiles.
Read the comment, then read the body. The comment says the function does its own permission check. The body filters by invoice_id and nothing else. Anyone who can guess or enumerate an invoice id — and who has your publishable key, which is in the browser — reads that invoice. The grant execute … to anon means they don't even need an account.
There's no error, no empty array, no 403. It returns the row, exactly as if it were working.
The scan above catches the second defect: the unpinned search_path, which is the mechanism by which an unqualified name inside an elevated function gets redirected to an attacker-controlled object.
The version that holds
alter table public.invoices enable row level security;
create or replace function public.get_invoice(invoice_id uuid)
returns setof public.invoices
language plpgsql
security definer
set search_path = '' -- 2. pinned; everything below is schema-qualified
as $$
begin
if auth.uid() is null then -- 1. identity check, first statement
raise exception 'not authenticated' using errcode = '42501';
end if;
return query
select i.*
from public.invoices i
where i.id = invoice_id
and i.owner_id = auth.uid(); -- the row-level check, done by hand
end;
$$;
-- 3. functions are granted to PUBLIC by default; take that back first.
-- (Revoking from anon as well logs a harmless "no privileges could be
-- revoked" notice, since anon only ever inherited the PUBLIC grant.)
revoke execute on function public.get_invoice(uuid) from public;
grant execute on function public.get_invoice(uuid) to authenticated;
Note the shape of the guard: if auth.uid() is null then raise, followed by an owner comparison in the where clause. Both are needed. Checking only that a user is signed in authorises every signed-in user to read every invoice — the same mistake as a policy that never references the current user, relocated into procedural code.
And do not write the guard as auth.uid() is null or i.owner_id = auth.uid(). That is the predicate that fails open, and it is just as wrong inside a function as it is inside a policy.
When this architecture is the right call
It genuinely is the right call sometimes:
- Multi-step writes that must be transactional and touch several tables under one permission decision.
- Expensive aggregate reads where per-row policy evaluation is the measured bottleneck.
- Logic that policies can't express cleanly — time windows, quotas, state machines.
And it's the wrong call when the rule is genuinely "a user sees their own rows". A policy expresses that in one line, the database enforces it on every path including ones you forget about, and there's no function to audit.
Most real projects end up mixed: policies as the baseline on every table, plus a small number of SECURITY DEFINER functions for the operations that need to cross that boundary deliberately. That combination keeps the backstop. Going policy-free everywhere removes it everywhere, to buy flexibility you only needed in a handful of places.
One more thing worth knowing if you adopt this: enabling RLS is not the same as removing grants. The Supabase RLS docs are blunt about it — a table in an exposed schema without RLS is readable and writable by any role holding a grant on it, and adding policies does not take those grants back. Since the 2026 Data API changes, grants are what expose a table at all, so audit them alongside your functions.
A 60-second self-check
-- Every SECURITY DEFINER function, with its search_path setting and
-- whether its body mentions auth.uid() at all.
select p.proname,
p.proconfig, -- null => search_path not pinned
pg_get_functiondef(p.oid) ~* 'auth\.(uid|jwt)\(\)' as has_auth_check,
array(select r.rolname
from pg_roles r
where has_function_privilege(r.rolname, p.oid, 'EXECUTE')
and r.rolname in ('anon', 'authenticated')) as callable_by
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.prosecdef;
Any row with has_auth_check = false and anon in callable_by is a public read of whatever that function touches. Run it after every migration; in this architecture it is the closest thing you have to a policy review.
Where a scanner helps and where it doesn't, stated plainly: GuardLayer flags every SECURITY DEFINER function in your migrations that doesn't pin its search_path — that's the finding in the scan further up — plus surrounding table-level mistakes like a table granted to anon without RLS, or RLS switched off in a migration. Two honest limits. A grant execute … to anon on a function is not currently a rule, so the grant in the broken sample above was not what tripped the scan. And no scanner judges whether a function's permission check is present or correct; that's a question about intent, not a pattern.
In a design where that check is the only control, this is a real gap, and it's the strongest practical argument for keeping policies as a floor wherever you reasonably can. A policy is something a machine can read. A hand-written guard mostly isn't.
FAQ
Does RLS with no policies really deny everything? Through the Data API with a publishable or user key, yes — no policy means no row qualifies. It does not restrict roles that bypass RLS, such as the table's owner or the service role.
Does a SECURITY DEFINER function bypass RLS on the tables it reads?
Effectively yes, when the function's owner also owns the table (or holds BYPASSRLS), because an owner skips its own policies. ALTER TABLE … FORCE ROW LEVEL SECURITY is the one thing that takes that exemption away.
Is SECURITY INVOKER an option here? Yes, and it's the default. An invoker-rights function runs as the caller, so RLS still applies — which means it can't serve data that policies deny. That's a feature if you keep policies, and useless if your design has none.
Do I still need set search_path = '' if the function only touches one table?
Yes. The risk is name resolution, not table count. Pin it and schema-qualify every reference.
Can I mix the two approaches?
That's the recommendation. Policies everywhere as the baseline, plus specific SECURITY DEFINER functions for operations that must cross it. You keep the database's backstop and still get the escape hatch.
Catch this before it ships — free
GuardLayer scans every push for this and 33 other Next.js + Supabase issues, with the exact fix inline.
No signup, no card — your code is scanned in memory and never stored.