PostgreSQL · 42P17
infinite recursion detected in policy for relation — fix without disabling RLS
Luca Urti
infinite recursion detected in policy for relation
Why does my Supabase policy fail with “infinite recursion detected in policy”?
The policy on a table runs a subquery against that same table — directly, or through a second table whose own policy reads back into the first. Evaluating the policy requires reading the table, reading the table requires evaluating the policy, and PostgreSQL cuts the loop and raises 42P17 rather than hanging. It shows up almost exclusively in multi-tenant and team schemas, where the natural way to express “you may see this organisation's rows if you are a member of it” is a subquery on the members table, whose own policy is “you may see membership rows for organisations you are a member of”.
The shape that causes it
A policy on org_members that reads org_members is the direct case. The indirect case is two tables pointing at each other: projects checks membership in org_members, and org_members checks membership in org_members again to decide who may see a membership row. The second one is harder to spot because neither policy mentions its own table.
The fix: take the lookup outside RLS
Put the membership lookup in a security definer function. Such a function runs with the privileges of its owner rather than the caller, so the query inside it is not subject to the calling user's policies and the loop never forms. The policy then calls the function instead of querying the table.
Two details matter. Pin set search_path = '' and fully qualify every table inside the function — a security definer function with a mutable search path can be hijacked by a caller who creates a same-named object in a schema earlier in the path. And keep the function narrow: it should answer one boolean question about the current user, not return data.
The fix
SECURITY DEFINER helper
-- Runs as the owner, so the read inside it is not filtered by the caller's
-- policies — which is exactly what breaks the recursion.
create or replace function public.is_org_member(org uuid)
returns boolean
language sql
stable
security definer
set search_path = ''
as $$
select exists (
select 1 from public.org_members m
where m.org_id = org and m.user_id = auth.uid()
);
$$;
revoke all on function public.is_org_member(uuid) from public;
grant execute on function public.is_org_member(uuid) to authenticated;
drop policy if exists "members can read projects" on public.projects;
create policy "members can read projects"
on public.projects
for select
to authenticated
using (public.is_org_member(org_id));The fix that works and costs you the database
The answer that appears fastest is alter table public.org_members disable row level security;. The recursion stops, because there is no longer a policy to recurse into — and the membership table, which maps every user to every organisation they belong to, becomes readable by anyone holding the anon key. It is usually the single most sensitive table in a multi-tenant schema, since it is the map of who belongs to whom. The other version is granting the function to anon as well as authenticated, which turns a membership check into an oracle anyone can query.
Whether the trap is already in your repo is a question you can answer
Sentris reads the SQL and the client code, so it reports the shortcut above where it was actually taken — a service_role key in a browser bundle, a policy that is using (true), a table with RLS switched off. A scan needs no account and no card. How often it is wrong is measured and published.