Example finding How it works Coverage PENTEST METHODOLOGY DOCS PRICING FAQ MCP CONTACT LOG IN SIGN UP →

Supabase error: infinite recursion detected in policy

FIX / RLS RECURSION / 8 MIN READ

The error “infinite recursion detected in policy” means that a Row Level Security (RLS) policy reads the table that it protects, so Postgres runs the policy again and again. Fix it by moving the lookup into a security definer function in a private schema, and call that function from the policy. The function reads the table without RLS, so the loop stops. This guide shows how to find the loop, fix it, and check that the fix did not open the table to everyone.

What does “infinite recursion detected in policy” mean?

Postgres raises this error when it finds a loop in policy checks. The full message has the form infinite recursion detected in policy for relation "table_name". The error code is 42P17. The Supabase RLS documentation describes this case and the fix.

A policy is a SQL condition. Postgres adds the condition to every query on the table. If the condition contains a subquery on a table with RLS, Postgres also applies the policies of that table. A loop happens when these policies lead back to the first table.

There are two common loops:

Loop Example
Direct A policy on members reads members to find the teams of the user.
Indirect A policy on lists reads list_members. A policy on list_members reads lists.

AI coding tools write the direct loop often. You ask for “users can see the other members of their team”. The tool writes a policy on members with a subquery on members. The policy looks correct. It fails when the first query runs.

The error is a safe failure. Postgres stops the query and returns no rows. Your data is not open when you see this error. The danger starts when you try a quick fix. The next sections show which quick fixes to avoid.

How do I confirm which policy causes the loop?

Read the error message first. The table name in the message is the table where Postgres detected the loop. The policy that starts the loop is on this table or on a table that it reads.

Follow these steps:

  1. Open the SQL Editor in the Supabase dashboard.
  2. Run the query below. It lists every policy on the table that the error names.
  3. Read the qual column for using conditions and the with_check column for with check conditions.
  4. Look for a select that reads the same table, or a table that has its own policies.
select schemaname, tablename, policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname = 'public'
  and tablename = 'members';

Replace members with the table from the error message. A policy that causes a direct loop looks like this:

create policy "team members see each other" on public.members
  for select to authenticated
  using (
    team_id in (
      select team_id from public.members
      where user_id = (select auth.uid())
    )
  );

The subquery reads public.members. Postgres applies the policy of public.members to that subquery. The policy runs again, and the loop starts.

To find an indirect loop, repeat the query for each table that the qual column names. Make a list of the tables. If table A appears in the policies of table B and table B appears in the policies of table A, you found the loop.

To see the error in the app, call the REST API with a user token. The error appears in the response body:

curl "https://YOUR-PROJECT.supabase.co/rest/v1/members?select=*" \
  -H "apikey: YOUR_ANON_KEY" \
  -H "Authorization: Bearer USER_ACCESS_TOKEN"

Use a real access token of a test user. Do not use the service_role key for this test. That key bypasses RLS, so the loop does not run, and you cannot reproduce the error.

How do I fix the recursion?

Use a security definer function. A function with this option runs with the permissions of its owner, and the owner is normally a role that bypasses RLS. The query inside the function does not run the policies of the table. The loop stops.

Warning: Do not create the function in the public schema. The Supabase documentation states that a security definer function in an exposed schema is callable over the Data API with the creator’s privileges. Use a private schema that the API does not expose.

Caution: Do these steps on a copy of the database first if your app has real users. A wrong policy can lock users out or show their data to other users.

  1. Create the private schema. Run create schema if not exists private;.
  2. Create the function. The function returns the values that the policy needs. Set search_path to an empty string and write schema names in the body.
  3. Remove access for public roles. Revoke execute from public. Then grant it only to the role that needs it.
  4. Drop the old policy. Use drop policy with the exact policy name.
  5. Create the new policy. The policy calls the function.
  6. Test with a real user token. Use the curl command above.

The example below fixes the direct loop from the previous section:

create schema if not exists private;

create function private.user_team_ids()
returns setof uuid
language sql
security definer
set search_path = ''
stable
as $$
  select team_id from public.members
  where user_id = (select auth.uid())
$$;

revoke execute on function private.user_team_ids() from public;
grant usage on schema private to authenticated;
grant execute on function private.user_team_ids() to authenticated;

drop policy "team members see each other" on public.members;

create policy "team members see each other" on public.members
  for select to authenticated
  using ( team_id in (select private.user_team_ids()) );

This pattern comes from the Supabase RLS documentation. Each part has a purpose:

Part Purpose
security definer The function reads the table without the policies of the table.
set search_path = '' The caller cannot replace table names with objects of the caller.
stable Postgres can reuse the result inside one statement.
revoke ... from public Postgres grants execute to everyone by default. The revoke removes this grant.
private schema The Data API does not expose the function as a callable endpoint.

Because search_path is empty, write every table with its schema name, such as public.members. A name without a schema fails inside the function.

The function bypasses RLS only when the owner role can bypass it. The Supabase documentation states that a function owned by a role without bypassrls, or a function that reads a table with force row level security, stays recursive. If the error continues after the change, check the owner of the function and check for force row level security on the table.

Is there a fix without a function?

Sometimes. Use these options when they fit your data model:

  1. Compare columns of the same row. A policy such as user_id = (select auth.uid()) has no subquery, so it cannot loop. Use it when each row has an owner column.
  2. Store the check value in the JWT. A policy can read a claim with auth.jwt(). Use only claims that the user cannot change. Do not read roles from user_metadata, because a user can edit that field.
  3. Split the table. Put the data that the policy needs in a table that has no policy loop. Keep that table small and read-only for users.

If your policy needs rows from other users, such as a member list, the function is the best choice.

What must I check after the fix?

The fix changes who can read data, so test it. A policy that returns an error is safe. A policy that returns all rows is not safe.

  1. Test as a member. Call the API with the token of a user who belongs to a team. Confirm that the user sees only the rows of that team.
  2. Test as an outsider. Call the API with the token of a user who belongs to no team. Confirm that the response is an empty list.
  3. Test as an anonymous visitor. Call the API with only the anon key. Confirm that the response is an empty list or an error.
  4. Test writes. Try an insert, an update, and a delete as each user type. The using and with check conditions can differ.
  5. Check the function grants. Run the query below. Only the roles that you chose must have execute.
select routine_schema, routine_name, grantee, privilege_type
from information_schema.routine_privileges
where routine_schema = 'private';

The result must not list PUBLIC or anon.

Read the Supabase RLS guide on VibeEval for a full test plan. For the meaning of the anon key in these tests, read Supabase anon key: is it safe to expose?.

How do I check if a quick fix opened my data?

People often apply a quick fix before they find the real cause. Check for these fixes now, even if you think you did not use them.

  1. Search for open policies. The query below finds policies that always return true.
  2. Search for tables with RLS off. A table with RLS off is open to every role that has a grant.
  3. Read the migration history. Look in your supabase/migrations folder or your SQL Editor history for disable row level security and using (true).
  4. Check where the app uses the service_role key. If a tool moved the query to the browser with this key, rotate the key now. See what to do about a leaked service_role key.
select tablename, policyname, cmd, roles, qual
from pg_policies
where schemaname = 'public'
  and (qual = 'true' or with_check = 'true');

select relname, relrowsecurity
from pg_class
where relnamespace = 'public'::regnamespace
  and relkind = 'r'
  and not relrowsecurity;

The first query lists policies that allow every row. The second query lists tables without RLS. Review each result. A public table such as a list of product names can stay open on purpose. A table with user data must not stay open.

If a table was open, check the Logs section of the dashboard for API requests to that table. Look for requests from unknown IP addresses and for large reads. The logs show only the period that your plan keeps. An empty log does not prove that nobody read the table.

How do I prevent the loop next time?

Use these rules for every new policy.

  1. Do not read the protected table in its own policy. If you need such a read, use a security definer function from the start.
  2. Keep helper functions in a private schema. Do not put them in public.
  3. Set search_path = '' on every security definer function. Write schema names in the body.
  4. Use (select auth.uid()) in policies. The Supabase documentation states that the wrapper lets Postgres cache the result for each statement. It is also the form that the documentation recommends.
  5. Write one policy for one action and one role. Short policies are easier to read than long ones with or conditions.
  6. Test each new policy with three user types. Use a member, an outsider, and an anonymous visitor.
  7. Review AI-generated policies before you apply them. Search the SQL for a from clause that names the table of the policy.
  8. Keep the policies in migration files. A reviewer can then read each change in a pull request.

The Supabase database linter also reports problems in your schema, such as a function with a mutable search_path. Run it after you add helper functions.

Tell your AI coding tool the rule in the project instructions. Use a sentence such as: “A policy must never select from its own table. Use a security definer function in the private schema with search_path set to an empty string.”

When should I get help?

Get help in these cases:

  • The error continues after you use the function, and the owner of the function and the table settings look correct.
  • Your policies have more than three tables that read each other. Ask a database engineer to review the model.
  • You find that a table with user data was open for a period of time. Treat the data as read by others. You may have legal duties to report this.
  • You do not know which role owns the function, or you cannot read pg_policies. Ask the person who set up the project.
  • You see requests in the logs that you cannot explain.

After the fix, run the three user tests again on the live project. Confirm that each user type sees only the rows that this user type must see.

Common questions

Why does the error say 'for relation'?
Postgres names the table where it found the loop. The table in the message is the one whose policy runs again and again. Start your search with that table.
Can I fix the error by turning off RLS?
No. The error stops, but every visitor with your anon key can then read and write the table. Fix the policy and keep RLS on.
Does the error mean my data leaked?
No. Postgres stops the query and returns nothing. The risk comes from the quick fixes that people try next, such as `using (true)` or a disabled policy.
Should I use the service_role key to avoid the error?
No. The service_role key bypasses RLS and must never be in browser code. Use a security definer function on the server side of the database instead.
Do views or joins also cause this error?
Yes. A policy on table A that reads table B can loop if a policy on table B reads table A. The fix is the same.

Test your rls from outside

A VibeEval scan sends requests to your deployed Supabase API as an anonymous visitor and reports tables that return rows when a policy is missing or too open.

14-day free trial · No credit card · Cancel anytime

SCAN YOUR APP →