Supabase error: infinite recursion detected in policy
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:
- Open the SQL Editor in the Supabase dashboard.
- Run the query below. It lists every policy on the table that the error names.
- Read the
qualcolumn forusingconditions and thewith_checkcolumn forwith checkconditions. - Look for a
selectthat 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.
- Create the private schema. Run
create schema if not exists private;. - Create the function. The function returns the values that the policy needs. Set
search_pathto an empty string and write schema names in the body. - Remove access for public roles. Revoke
executefrompublic. Then grant it only to the role that needs it. - Drop the old policy. Use
drop policywith the exact policy name. - Create the new policy. The policy calls the function.
- Test with a real user token. Use the
curlcommand 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:
- 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. - 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 fromuser_metadata, because a user can edit that field. - 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.
- 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.
- 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.
- Test as an anonymous visitor. Call the API with only the anon key. Confirm that the response is an empty list or an error.
- Test writes. Try an
insert, anupdate, and adeleteas each user type. Theusingandwith checkconditions can differ. - 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.
- Search for open policies. The query below finds policies that always return true.
- Search for tables with RLS off. A table with RLS off is open to every role that has a grant.
- Read the migration history. Look in your
supabase/migrationsfolder or your SQL Editor history fordisable row level securityandusing (true). - Check where the app uses the
service_rolekey. 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.
- Do not read the protected table in its own policy. If you need such a read, use a
security definerfunction from the start. - Keep helper functions in a private schema. Do not put them in
public. - Set
search_path = ''on everysecurity definerfunction. Write schema names in the body. - 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. - Write one policy for one action and one role. Short policies are easier to read than long ones with
orconditions. - Test each new policy with three user types. Use a member, an outsider, and an anonymous visitor.
- Review AI-generated policies before you apply them. Search the SQL for a
fromclause that names the table of the policy. - 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'?
Can I fix the error by turning off RLS?
Does the error mean my data leaked?
Should I use the service_role key to avoid the error?
Do views or joins also cause this error?
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