You wrote an RLS policy that checks another table – and suddenly Supabase throws:
ERROR: infinite recursion detected in policy for relation "profiles"
In this article I'll show you why that recursion happens and how to fix it with a clean policy design (or a SECURITY DEFINER helper).
The Problem: Why RLS Goes Into an Infinite Loop
Postgres evaluates Row Level Security for every row. If policy A reads table B and policy B reads table A again (or the same table), you get recursion – Postgres aborts instead of looping forever.
Classic case: a profiles policy checks team_members, and the team_members policy reads profiles again.
-- Problematic: policy on profiles reads team_members ...
CREATE POLICY "profiles_select" ON profiles
FOR SELECT USING (
EXISTS (
SELECT 1 FROM team_members
WHERE team_members.user_id = auth.uid()
AND team_members.profile_id = profiles.id
)
);
-- ... and policy on team_members reads profiles
CREATE POLICY "team_members_select" ON team_members
FOR SELECT USING (
EXISTS (
SELECT 1 FROM profiles
WHERE profiles.id = team_members.profile_id
AND profiles.user_id = auth.uid()
)
);
The Solution: Break the Recursion on Purpose
Step 1: Prefer auth.uid() Only
First check whether you need a second table at all. Often this is enough:
CREATE POLICY "profiles_own_rows" ON profiles
FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "team_members_own_rows" ON team_members
FOR SELECT USING (auth.uid() = user_id);
Step 2: Helper Function with SECURITY DEFINER
When you must check roles/teams, wrap the lookup in a function that bypasses RLS – but only for that narrow check:
CREATE OR REPLACE FUNCTION public.is_team_member(p_profile_id uuid)
RETURNS boolean
LANGUAGE sql
SECURITY DEFINER
SET search_path = public
STABLE
AS $$
SELECT EXISTS (
SELECT 1
FROM team_members
WHERE profile_id = p_profile_id
AND user_id = auth.uid()
);
$$;
-- Policy uses the function instead of a direct join
CREATE POLICY "profiles_team_select" ON profiles
FOR SELECT USING (
auth.uid() = user_id
OR public.is_team_member(id)
);
Important: always set SET search_path = public and keep the function as narrow as possible – otherwise you risk leaking data.
Step 3: Test the Policies
-- Test as a concrete user (SQL Editor)
SELECT set_config('request.jwt.claim.sub', 'USER-UUID-HERE', true);
SELECT * FROM profiles;
Or test from the client with the normal anon/authenticated key – not the service-role key, which bypasses RLS.
Common Mistakes
- Policy queries the same table:
EXISTS (SELECT 1 FROM profiles WHERE …)inside a policy onprofilesoften causes recursion. - Two tables referencing each other: resolve one side with
SECURITY DEFINERor a denormalized column (e.g.owner_id). - Service role in the frontend: hides the bug locally, then fails in production with the anon key.
Complete Working Example
-- Clean pattern for profiles + memberships
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE team_members ENABLE ROW LEVEL SECURITY;
CREATE OR REPLACE FUNCTION public.current_user_team_ids()
RETURNS SETOF uuid
LANGUAGE sql
SECURITY DEFINER
SET search_path = public
STABLE
AS $$
SELECT team_id FROM team_members WHERE user_id = auth.uid();
$$;
CREATE POLICY "profiles_select" ON profiles
FOR SELECT USING (
user_id = auth.uid()
OR id IN (
SELECT profile_id FROM team_members
WHERE team_id IN (SELECT public.current_user_team_ids())
)
);
CREATE POLICY "team_members_select" ON team_members
FOR SELECT USING (
user_id = auth.uid()
OR team_id IN (SELECT public.current_user_team_ids())
);
Troubleshooting Checklist
- ✅ Which two policies/tables reference each other?
- ✅ Can the check be done with
auth.uid()only? - ✅ If not: helper with
SECURITY DEFINER+ tightsearch_path - ✅ Test with an authenticated session, not the service role
- ✅ After the fix: drop the old recursive policies (
DROP POLICY …)
Conclusion
infinite recursion detected in policy is not a Supabase bug – it is Postgres stopping a circular RLS check. Decouple the policies (via auth.uid() or a slim SECURITY DEFINER function) and the error goes away.
Additional Resources
- permission denied for table (42501)
- PGRST116: multiple (or no) rows returned
- How to query users table?
- Supabase Auth: Invalid login credentials
- Supabase Edge Functions: Invalid JWT / 401
- Supabase Storage 403 RLS on upload
- Supabase Edge Functions CORS Error Fix
- Supabase Storage File Upload Guide
- RLS insert error: new row violates row-level security policy
- Supabase RLS documentation
Still stuck in a recursive policy? Leave a comment – happy to take a look.
Comments