Fix Supabase RLS Infinite Recursion Error (Postgres Policy Loop Explained — Production Fix 2026)
Technology

Fix Supabase RLS Infinite Recursion Error (Postgres Policy Loop Explained — Production Fix 2026)

If your Supabase query returns `infinite recursion detected in policy for relation "X"`, your RLS policy is querying the same table it protects. Here's exactly why it loops, and three production-grade fixes that don't leak data.

2026-05-19
9 min read
Fix Supabase RLS Infinite Recursion Error (Postgres Policy Loop Explained — Production Fix 2026)

This is one of the most-searched Supabase errors and one of the most-misunderstood. The error message is precise but the actual cause is subtle — Postgres isn't broken, your policy is calling itself.

The problem#

You add an RLS policy that lets users read rows they have access to. The first query against that table returns:

ERROR:  infinite recursion detected in policy for relation "projects"
LINE 1: SELECT * FROM projects WHERE ...

The query never executes. Worse, the error fires before any application logic runs, so your app sees a generic 500 with no actionable context.

Symptoms#

You're hitting this if:

  1. The error mentions a specific relation (for relation "X") and infinite recursion.
  2. You recently added or modified an RLS policy on that table.
  3. The policy's USING or WITH CHECK clause contains a SELECT ... FROM X against the same table.
  4. The same query works when you connect as the postgres role (which bypasses RLS).

Root cause#

Here's a textbook trigger — sharing rows in a projects table when the owner can also see members:

sql
CREATE POLICY "members can read projects" ON projects
FOR SELECT
USING (
  auth.uid() = owner_id
  OR auth.uid() IN (
    SELECT user_id FROM projects WHERE id = projects.id  -- recursion!
  )
);

Postgres has to evaluate the policy for the inner SELECT FROM projects too. That inner select re-applies the same policy, which contains another inner select, which re-applies the policy, and so on. Postgres detects the loop and aborts.

Key insight: RLS runs on every read, including reads inside your own policy. A policy on table X that reads from X is always a recursion risk unless you bypass RLS explicitly.

Move the lookup into a function that runs with elevated privileges and skips RLS:

sql
-- Returns true if the current user is a member of the given project.
-- SECURITY DEFINER means it runs as the function owner, bypassing RLS.
CREATE OR REPLACE FUNCTION public.is_project_member(project_id uuid)
RETURNS boolean
LANGUAGE sql
SECURITY DEFINER
SET search_path = public
AS $$
  SELECT EXISTS (
    SELECT 1
    FROM project_members
    WHERE project_members.project_id = is_project_member.project_id
      AND project_members.user_id = auth.uid()
  );
$$;

REVOKE EXECUTE ON FUNCTION public.is_project_member(uuid) FROM public;
GRANT EXECUTE ON FUNCTION public.is_project_member(uuid) TO authenticated;

Then rewrite the policy to call the function:

sql
DROP POLICY "members can read projects" ON projects;

CREATE POLICY "members can read projects" ON projects
FOR SELECT
USING (
  auth.uid() = owner_id
  OR public.is_project_member(id)
);

No recursion. The function reads from project_members (a different table), and even if it didn't, SECURITY DEFINER skips RLS evaluation.

Three rules when using SECURITY DEFINER:

  1. Always set search_path — without it, a malicious user can hijack the function by creating a schema with the same name. The SET search_path = public line is non-negotiable.
  2. Keep the WHERE clause narrow — only return what the caller is allowed to know.
  3. Revoke from public, grant to authenticated — never let anonymous users call it unless that's the intent.

Fix 2 — Restructure the policy to avoid the protected table#

If you can express access in terms of auth.uid(), a JWT claim, or a different table, you don't need SECURITY DEFINER at all.

sql
-- Membership lives in a separate table → no recursion possible.
CREATE POLICY "members can read projects" ON projects
FOR SELECT
USING (
  auth.uid() = owner_id
  OR EXISTS (
    SELECT 1 FROM project_members
    WHERE project_members.project_id = projects.id
      AND project_members.user_id = auth.uid()
  )
);

This only works because project_members is a different relation. If project_members itself has an RLS policy that queries projects, you re-introduce the loop. Keep membership tables RLS-simple — usually just user_id = auth.uid().

Fix 3 — Use JWT claims for tenant scoping#

If you store the user's tenant/org in the JWT, the policy can read it directly without touching any table:

sql
CREATE POLICY "users see only their org's projects" ON projects
FOR SELECT
USING (
  org_id = (auth.jwt() ->> 'org_id')::uuid
);

Zero recursion risk — auth.jwt() is in-memory, not a query. Add the org_id claim via a Supabase Auth Hook on token issuance.

Verification#

After applying the fix:

sql
-- 1. Confirm the policy compiles
SELECT polname, polqual FROM pg_policy WHERE polrelid = 'projects'::regclass;

-- 2. Test as a real user (use Supabase SQL editor with "Run as authenticated user")
SET LOCAL ROLE authenticated;
SET LOCAL request.jwt.claims TO '{"sub": "00000000-0000-0000-0000-000000000001"}';
SELECT count(*) FROM projects;
RESET ROLE;

The count should return without an error. Then test from the application client to confirm end-to-end.

Debug checklist#

  1. Read the error message — note the exact relation name. That's the recursion source.
  2. Inspect every RLS policy on that table: SELECT * FROM pg_policies WHERE tablename = 'X';
  3. Look in each policy's qual and with_check for a SELECT ... FROM X against the same table.
  4. Decide which fix applies:
    • Same-table self-reference required → Fix 1 (SECURITY DEFINER)
    • Access derived from another table → Fix 2 (restructure)
    • Access derived from JWT/claims → Fix 3 (claims)
  5. Apply the fix in a migration, not the SQL editor — RLS changes must be versioned.
  6. Run the verification queries above before redeploying the app.

Prevention#

  • Never reference the protected table inside its own policy. Treat this as a hard rule. If you need to, that's a sign the access model belongs in a separate _members or _acl table.
  • Test RLS policies in CI with pgTAP or a Supabase-local test harness — SELECT count(*) as different roles, and assert expected rows.
  • Document every SECURITY DEFINER function in a comment block explaining why RLS is being bypassed and what the safety boundary is.
  • Keep policies pureauth.uid(), auth.jwt(), equality checks, and joins to membership tables. If a policy is more than 4 lines, it's probably hiding bugs.
  • Watch the pg_policies system view in code review. Any new policy that mentions its own table name is a recursion candidate.

The recursion error feels like a bug in Supabase the first time you see it. It's not — Postgres is protecting you from an infinite loop you wrote. Fix the policy shape and it goes away.

Frequently Asked Questions

|

Have more questions? Contact us