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.
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:
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:
- The error mentions a specific relation (
for relation "X") andinfinite recursion. - You recently added or modified an RLS policy on that table.
- The policy's
USINGorWITH CHECKclause contains aSELECT ... FROM Xagainst the same table. - The same query works when you connect as the
postgresrole (which bypasses RLS).
Root cause#
Here's a textbook trigger — sharing rows in a projects table when the owner can also see members:
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.
Fix 1 — SECURITY DEFINER function (recommended)#
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:
-- 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:
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:
- Always pin
search_path— without it, a caller can create an object (a table, function or operator, including a temporary table, sincepg_tempis 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). - Keep the WHERE clause narrow — only return what the caller is allowed to know.
- Keep it out of exposed schemas, revoke from
public, grant toauthenticated— aSECURITY DEFINERfunction in a schema exposed by the Data API is callable over HTTP with its owner's privileges. New functions grantEXECUTEtoPUBLICby 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.
-- 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:
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:
-- 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#
- Read the error message — note the exact relation name. That's the recursion source.
- Inspect every RLS policy on that table:
SELECT * FROM pg_policies WHERE tablename = 'X'; - Look in each policy's
qualandwith_checkfor aSELECT ... FROM Xagainst the same table. - 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)
- Apply the fix in a migration, not the SQL editor — RLS changes must be versioned.
- 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
_membersor_acltable. - Test RLS policies in CI with
pgTAPor a Supabase-local test harness —SELECT count(*)as different roles, and assert expected rows. - Document every
SECURITY DEFINERfunction 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_policiessystem 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
Still stuck? Paste your error.
Paste the error message or stack trace — we check it against every fix and tool on the site.
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.
Continue Reading
Test Supabase RLS Policies Before You Ship
Every RLS leak I have seen shipped the same way: the policy was tested in a context that does not enforce it. The Supabase SQL editor runs as a privileged role, the service key carries `BYPASSRLS`, and the table owner is exempt from its own policies unless you say otherwise. Three green checks, zero enforcement. This is the procedure that actually tests a policy — plus the two Postgres flags that decide whether your test means anything.
Supabase 42501: permission denied for schema auth
A view over `auth.users`, a policy that joins it, or a client query against it all fail the same way: `ERROR: 42501: permission denied for schema auth`. It is not a missing GRANT you forgot — Supabase keeps the `auth` schema out of reach of the API roles deliberately, and granting your way in is the one fix you should not apply. The supported route is a security definer function.
Supabase bucket RLS policy for table objects fix
This Storage error almost never means Supabase is broken. It usually means your upload path, your RLS policy, or your use of `upsert` does not match how Storage actually authorizes writes.


