Guides / infinite recursion detected in policy
Supabase "infinite recursion detected in policy for relation": what it means and how to fix it
Your query fails with infinite recursion detected in policy for relation "profiles" (Postgres error 42P17). A Row Level Security policy reads a table whose policies lead back to the original policy, so Postgres stops the query. The fix is to break the loop. Turning RLS off is not a fix.
The short answer
Supabase's troubleshooting article describes it as a circular dependency between RLS policies. It happens when:
- A policy queries the same table it protects, for example an "admins can read all profiles" policy on
profilesthat looks up the caller's role inprofiles. - Two tables' policies read each other, for example a policy on
listscheckslist_members, and a policy onlist_memberscheckslists.
The error can also show up on Storage requests, if a Storage policy references a table with self-referencing or mutually recursive policies. Supabase gives two fixes: rewrite the policies so there's no cycle, or move the lookup into a security definer function.
How to find the loop
1. List the policies that read other tables
Run this in the SQL Editor. It prints policy expressions only, never data or keys:
select tablename, policyname, cmd, roles, qual, with_check from pg_policies where schemaname = 'public' and (qual ilike '%select%' or with_check ilike '%select%') order by tablename;
Look for a policy whose qual or with_check selects from its own tablename, or two tables whose policies select from each other.
2. Reproduce it as a real user
Table owners and superusers normally skip RLS, so a test from the SQL Editor's default connection may not show the error. Switch to the app's role inside a transaction and roll back:
begin; set local role authenticated; set local request.jwt.claim.sub = '00000000-0000-0000-0000-000000000001'; -- a real user's id select count(*) from public.profiles; rollback;
How to fix it
Option 1: remove the cycle
Often one side only needs to check the caller's own rows. A membership policy such as using ( (select auth.uid()) = user_id ) doesn't read any other table, so the other table's policy can query it without looping back.
Option 2: a security definer helper in a private schema
Supabase's RLS guide says a security definer function runs as the role that created it, and when that's a superuser like postgres, the function has bypassrls. The lookup inside it doesn't trigger the table's policies, so the loop never starts:
That holds while the owner can bypass RLS: a function owned by a role without bypassrls, or one reading a table set to force row level security, evaluates the policy again and stays recursive.
create schema if not exists private;
create function private.is_admin()
returns boolean
language plpgsql
security definer -- runs as the creator
set search_path = '' -- every name inside must be schema-qualified
stable
as $$
begin
return exists (
select 1 from public.profiles
where id = (select auth.uid()) and role = 'admin'
);
end;
$$;
revoke execute on function private.is_admin() from public, anon;
grant usage on schema private to authenticated;
grant execute on function private.is_admin() to authenticated;
drop policy if exists "Admins read all profiles" on public.profiles;
create policy "Admins read all profiles"
on public.profiles for select
to authenticated
using ( (select private.is_admin()) );
Three details matter:
- Keep it out of exposed schemas. Supabase says a security definer function in an exposed schema is callable over the Data API with the creator's privileges. Never create one in a schema listed under "Exposed schemas" in your API settings, which by default includes
public. - Pin
search_path = ''and schema-qualify every name. Without it, a caller can point an unqualified name at their own object. See Function Search Path Mutable. - Revoke execute from
publicandanon. By default any role can run a database function. Useauth.uid()inside the function rather than a user-id parameter, so it only answers about the caller.
Wrapping the call in (select ...) lets Postgres run it once per statement instead of once per row, per Supabase's RLS guide.
Don't "fix" it by turning protection off. alter table ... disable row level security on a membership or profiles table stops the error and can make that table readable by any role with a grant on it, which on many projects includes anon. A policy of using (true) or a service_role key in your frontend have the same effect. See RLS policies that are always true and service_role key exposed in client code.
Confirm it worked
Re-run the step 2 test as an admin user and as a normal user: both should return without the error, and the normal user should only see the rows the policies allow. Then run it with set local role anon; and confirm signed-out requests see nothing they shouldn't.
free check
Did a secret key end up in your frontend?
If you pasted a service_role or secret key into client code while chasing this error, LockedVibe's free Leak Checker can look at your live site for exposed keys, the way any visitor's browser would. It can't see your database policies. For a full review, see the $99 Scan + Report.
Sources
Every Postgres and Supabase detail above was checked against these official pages. Last checked Nov 2026. If behavior changed, the docs below are the source of truth.
- Supabase troubleshooting: RLS policy causes infinite recursion (causes, Storage, two options)
- Supabase: Row Level Security (security definer functions, exposed schemas, search_path, 42P17, select wrapping)
- Supabase: Database Functions (function privileges, revoke execute)
- Supabase: Using Custom Schemas (public is exposed on data APIs by default)
- PostgreSQL: Error codes (42P17 invalid_object_definition)
- PostgreSQL: Row Security Policies (owners and BYPASSRLS skip RLS)
- PostgreSQL: pg_policies view