All guidesSupabase RLS and keys
On this page

Supabase RLS error

Supabase

infinite recursion detected in policy for relation

By Ohad Krispin, founder of VibeRaven · LinkedIn

"infinite recursion detected in policy for relation" (Postgres error 42P17) means a row level security policy on a table reads that same table, directly or through another table's policy that reads back: to check the policy, Postgres has to apply the policy again. The classic case in Supabase apps is an admin or role check such as using ((select role from profiles where id = auth.uid()) = 'admin') on profiles itself. Every query on the table then fails, and the REST API returns HTTP 500 with code 42P17. Move the lookup into a security definer function in a schema the Data API does not expose, with set search_path = '', and call it from the policy as (select private.is_admin()). Reproduced on a local Supabase stack (Postgres 17.11, PostgREST 16.5) and checked against the Postgres source on 2026-10-27.

A policy on profiles that sub-selects profiles: psql and the REST API both return 42P17 infinite recursion detected in policy for relation profiles, HTTP 500; with a security definer is_admin() function the same request returns 200.
A policy on profiles that sub-selects profiles: psql and the REST API both return 42P17 infinite recursion detected in policy for relation profiles, HTTP 500; with a security definer is_admin() function the same request returns 200.

Why the policy loops

Postgres applies a table's policies to every query on it, including the sub-select inside a policy. A policy on profiles that selects from profiles makes Postgres apply the profiles policies to that sub-select, which contains the same sub-select, and so on; Postgres stops while it rewrites the query and raises 42P17 before it reads a row. The same happens across two tables: a policy on teams that reads members, and a policy on members that reads teams.

-- loops: the policy on profiles reads profiles
create policy "admins can read all profiles" on public.profiles
for select to authenticated
using ((select role from public.profiles where id = (select auth.uid())) = 'admin');

The fix: a security definer function outside the exposed schemas

A security definer function runs as its owner. Owned by postgres, its query skips RLS, so the policy no longer calls itself. Supabase's RLS guide uses the same pattern, a function in a private schema with set search_path = '', and says never to create one in a schema listed under Exposed schemas, because the Data API would let users call it directly. Grant execute to authenticated only: in our run, without that grant the request failed with permission denied for function is_admin.

create schema if not exists private;

create or replace function private.is_admin()
returns boolean
language sql
security definer
set search_path = ''
stable
as $$
  select exists (
    select 1 from public.profiles
    where id = (select auth.uid()) and role = 'admin'
  );
$$;

revoke execute on function private.is_admin() from public, anon;
grant execute on function private.is_admin() to authenticated;

drop policy "admins can read all profiles" on public.profiles;
create policy "admins can read all profiles" on public.profiles
for select to authenticated
using ((select private.is_admin()));

Keep roles out of the table the policy protects

A separate user_roles table read only through the definer function, or a custom claim in the JWT read with auth.jwt(), avoids the loop for good. Do not read a role from user_metadata in a policy: users can change their own user_metadata. Supabase's RLS guide says the same and points to app_metadata, which only the server sets.

Lovable and other generated apps

Generated admin panels often add a policy like "Admins can view all profiles" that checks the role on profiles itself, and the app breaks the moment it is applied: every page that reads profiles shows an error, or an empty list where the client swallows it. Ask the agent for a security definer has_role or is_admin function in a private schema, and check the migration it writes against the code above.

Checklist

How to fix it, then check the fix

  1. 01Read the relation name in the error: it is the table whose policies loop, not always the table you queried.
  2. 02
    List that table's policies and look for a sub-select of the same table, or of a table whose policy reads back:
    select policyname, cmd, qual, with_check
    from pg_policies
    where schemaname = 'public' and tablename = 'profiles';
  3. 03Move the lookup into a security definer function in a schema that is not exposed, with set search_path = '', owned by postgres.
  4. 04Revoke execute on the function from public and anon, and grant it to the roles the policy is for.
  5. 05Run the same request as a normal user and as an admin: each gets 200 and only the rows it should see.

Related

Sources

Official docs behind this checklist

FAQ

FAQ for builders before launch

Does disabling RLS fix it?

It stops the error and exposes every row to anyone with the anon key. Fix the policy instead.

Why does it fail even for signed-out requests?

Postgres detects the loop while it rewrites the query, before it reads any rows or checks who you are, so every role the policy applies to gets the error.

Is a security definer function safe here?

It is the pattern Supabase documents, as long as it lives in a schema the API does not expose, sets search_path to an empty string, returns only a boolean, and only the roles that need it can execute it.