Supabase

Fix Supabase RLS Infinite Recursion (Production 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.

Mahdi Ben RhoumaMahdi Ben Rhouma
Updated
9 min read

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.

Shortcut: paste the error into our free Supabase RLS Policy Debugger to get the SECURITY DEFINER fix pattern directly.

The problem#

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

plaintext
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 — this is the same SECURITY DEFINER pattern Supabase's own Row Level Security guide recommends for breaking exactly this kind of recursive-policy cycle:

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.
-- It lives in a schema that the Data API does not expose.
CREATE SCHEMA IF NOT EXISTS private;
 
CREATE OR REPLACE FUNCTION private.is_project_member(project_id uuid)
RETURNS boolean
LANGUAGE sql
SECURITY DEFINER
SET search_path = ''
STABLE
AS $
  SELECT EXISTS (
    SELECT 1
    FROM public.project_members
    WHERE project_members.project_id = is_project_member.project_id
      AND project_members.user_id = (SELECT auth.uid())
  );
$;
 
REVOKE EXECUTE ON FUNCTION private.is_project_member(uuid) FROM public;
GRANT USAGE ON SCHEMA private TO authenticated;
GRANT EXECUTE ON FUNCTION private.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 private.is_project_member(id)
);

No recursion. The function reads from project_members (a different table), and because it runs as its owner, it also skips RLS on that table, provided the owner bypasses RLS. On Supabase the owner is postgres, which has bypassrls. A function owned by a role without bypassrls, or reading a table set to FORCE ROW LEVEL SECURITY, evaluates the policies again and can stay recursive (Supabase RLS guide; PostgreSQL row security policies).

Three rules when using SECURITY DEFINER:

  1. Always pin search_path — without it, a caller can create an object (a table, function or operator, including a temporary table, since pg_temp is searched first by default) that masks a name the function uses, and have it run with the owner's privileges. SET search_path = '' with schema-qualified names inside the body closes that gap (PostgreSQL: Writing SECURITY DEFINER Functions Safely).
  2. Keep the WHERE clause narrow — only return what the caller is allowed to know.
  3. Keep it out of exposed schemas, revoke from public, grant to authenticated — a SECURITY DEFINER function in a schema exposed by the Data API is callable over HTTP with its owner's privileges. New functions grant EXECUTE to PUBLIC by default, so revoke that before granting selectively.

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. SET LOCAL has no effect outside a transaction,
--    so wrap the test in BEGIN ... ROLLBACK.
BEGIN;
SET LOCAL ROLE authenticated;
SET LOCAL request.jwt.claims TO '{"sub": "00000000-0000-0000-0000-000000000001"}';
SELECT count(*) FROM projects;
ROLLBACK;

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 pure — auth.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.

Further reading: Test Supabase RLS Policies Before You Ship, Supabase RLS Policy Design Patterns Beyond the Basics.

Frequently Asked Questions

|

Have more questions? Contact us

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

Written by

Mahdi Ben Rhouma
Mahdi Ben Rhouma

Full-Stack Dev — Next.js & Supabase

Solo developer building SaaS products with Next.js and Supabase. Writing about production patterns the official docs skip.

Remote

One email a month — the latest explained errors

What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.