SecureStartKit
SecurityFeaturesPricingDocsBlogChangelog
Sign inBuy Now
May 28, 2026·Tutorial·SecureStartKit Team

Supabase RLS Not Working? 7 Symptoms and the Fix [2026]

Supabase RLS returning empty silently? The 7 failure symptoms plus the SQL editor procedure (set role + request.jwt.claims) that finds the cause.

Summarize with AI

On this page

  • Table of contents
  • Why does Supabase RLS fail silently instead of erroring?
  • How do you simulate an authenticated user in the SQL editor?
  • What are the 7 most common Supabase RLS failures and how do you fix each?
  • Symptom 1: SELECT returns empty silently
  • Symptom 2: "permission denied for table X"
  • Symptom 3: SELECT works but INSERT fails
  • Symptom 4: Query takes 11+ seconds under load
  • Symptom 5: Works in dev, fails on production
  • Symptom 6: Accidental service_role bypass
  • Symptom 7: Recursive policy infinite loop
  • How do you use EXPLAIN ANALYZE to find per-row auth.uid() calls?
  • The debugging session that taught me to wrap auth.uid()
  • When debugging RLS is a sign you need a different architecture

On this page

  • Table of contents
  • Why does Supabase RLS fail silently instead of erroring?
  • How do you simulate an authenticated user in the SQL editor?
  • What are the 7 most common Supabase RLS failures and how do you fix each?
  • Symptom 1: SELECT returns empty silently
  • Symptom 2: "permission denied for table X"
  • Symptom 3: SELECT works but INSERT fails
  • Symptom 4: Query takes 11+ seconds under load
  • Symptom 5: Works in dev, fails on production
  • Symptom 6: Accidental service_role bypass
  • Symptom 7: Recursive policy infinite loop
  • How do you use EXPLAIN ANALYZE to find per-row auth.uid() calls?
  • The debugging session that taught me to wrap auth.uid()
  • When debugging RLS is a sign you need a different architecture

When a Supabase RLS policy is deployed and silently broken, the cause is almost always one of seven specific symptoms: a SELECT that returns empty without erroring, a permission denied for table message, a SELECT that works but an INSERT that fails, a query that slows from milliseconds to 11+ seconds under load, a policy that works in dev but fails on production, an accidental service_role bypass, or a recursive policy that hits Postgres's stack depth limit. Each has a distinct root cause and a one-pass fix.

The RLS pillar covers how to write policies that hold up. This post is the other half: the diagnostic procedure for a policy that is already shipped, already deployed, and not behaving the way you expected. The Information Gain is a reproducible set role authenticated plus set request.jwt.claims procedure that runs inside the Supabase SQL editor (the same editor the pillar warns against for testing, but which is the right tool for debugging once you understand role-switching), plus the seven-symptom catalog with the EXPLAIN ANALYZE patterns that confirm each diagnosis.

TL;DR:

  • The SQL editor bypasses RLS because it runs as postgres. The fix is set local role authenticated; set local request.jwt.claims = '...' inside a transaction.
  • Empty results are not errors. Postgres returns data: [] when a policy filters every row out, not an error. The diagnostic is to check policy presence, auth.uid() resolution, and the USING expression in that order.
  • Seven symptoms cover the field. Silent empty SELECT, permission denied, SELECT works INSERT fails, 11s slow query, works-locally-fails-on-prod, service_role bypass, recursive policy.
  • EXPLAIN ANALYZE finds the wrapper bug. Per-row auth.uid() shows up as a filter line; wrapped (select auth.uid()) shows up as an InitPlan. Supabase's own benchmark: 179ms to 9ms with the wrapper [1].
  • The most common production cause is the service_role key. Half the "RLS not working" tickets resolve when the client is reinitialized with the anon key plus a real session, not the admin key.

Table of contents

  • Why does Supabase RLS fail silently instead of erroring?
  • How do you simulate an authenticated user in the SQL editor?
  • What are the 7 most common Supabase RLS failures and how do you fix each?
  • How do you use EXPLAIN ANALYZE to find per-row auth.uid() calls?
  • The debugging session that taught me to wrap auth.uid()
  • When debugging RLS is a sign you need a different architecture

Why does Supabase RLS fail silently instead of erroring?

Postgres treats RLS as a row filter, not as an authorization gate. When a SELECT runs against a table with RLS enabled and the policy's USING clause evaluates to false for every row, Postgres returns zero rows. No exception, no error code, no log line. The client receives { data: [], error: null } and the application assumes the user has no records, when in fact the user has records the policy is hiding.

That single design choice is the root of most "RLS not working" tickets. The query succeeded. The role had SELECT permission. The table had a policy. The policy just filtered everything out, and the failure mode is indistinguishable from "user genuinely owns no rows" without further diagnosis.

INSERT, UPDATE, and DELETE are different. When a write violates a WITH CHECK clause, Postgres raises new row violates row-level security policy for table "X" with code 42501. So an application that logs error.code will see write failures immediately but read failures never. That asymmetry is why "my SELECT returns empty" dominates the failure distribution.

The diagnostic order, in increasing cost:

  1. Confirm the policy exists and is enabled. In the Supabase dashboard, Authentication > Policies should list the policy. Or run select * from pg_policies where tablename = 'notes'; in the SQL editor.
  2. Confirm RLS is enabled on the table. select relrowsecurity from pg_class where relname = 'notes'; returns true if enabled. A table with policies but RLS disabled silently allows everything through.
  3. Confirm auth.uid() resolves for the caller. In an impersonated SQL editor session (see the next section), select auth.uid(); should return the user's UUID. A null return means the request never carried valid auth context.
  4. Confirm the USING expression matches at least one row. Run the expression as a WHERE clause without the policy in the way: select count(*) from notes where user_id = '<user-uuid>';. If that returns zero, the policy is correct and the user truly has no rows.

The first two cost one query each. The third needs the role-switching procedure below. The fourth often reveals that the application is passing the wrong user ID or that the rows were never inserted under the expected user_id.

How do you simulate an authenticated user in the SQL editor?

The SQL editor runs queries as the postgres superuser, which bypasses RLS entirely. To debug a policy as if you were a specific end user, wrap the test in a transaction and switch role plus claims with set local, then rollback to discard the session changes. PostgREST sets these exact two configuration parameters on every authenticated request behind the scenes [3], so simulating them in the editor reproduces the same conditions the policy runs under in production.

The full procedure:

-- Run inside a transaction so SET LOCAL is scoped to the block
begin;

-- Switch to the role PostgREST uses for authenticated requests
set local role authenticated;

-- Inject the JWT claims PostgREST would have set from the access token
set local request.jwt.claims = '{
  "sub": "11111111-1111-1111-1111-111111111111",
  "role": "authenticated",
  "email": "test@example.com",
  "app_metadata": {"role": "user"}
}';

-- Confirm auth.uid() resolves to the sub claim
select auth.uid();
-- -> 11111111-1111-1111-1111-111111111111

-- Now run the query the application would run
select id, title from public.notes;

-- Discard everything; the session returns to its prior state
rollback;

A few details that bite first-timers. The sub claim must be a real user UUID in auth.users if your policies join against auth.users or call helper functions that do; otherwise use any UUID. The role claim must be the string "authenticated" (not "user" or a custom role); PostgREST maps that exact string to the database role. Custom claims like app_metadata.role go inside the JSON exactly as the access token would carry them, and you can read them in policies with ((select auth.jwt()) -> 'app_metadata' ->> 'role') [2].

To debug an unauthenticated request, swap set local role authenticated for set local role anon and clear the claims with set local request.jwt.claims = '{}'. The anon role is what unauthenticated Supabase clients hit, and policies often need separate handling for it.

Two things this procedure does NOT cover. It does not validate the JWT signature (PostgREST does that before setting the claims, but you are bypassing that step). And it does not enforce expiration; an expired-in-production JWT will not fail in this simulation. For both of those, the test harness in a Server Action from the pillar's "How to test policies correctly" section is the right tool because it goes through the real client SDK.

What are the 7 most common Supabase RLS failures and how do you fix each?

The seven symptoms below cover roughly 95% of RLS debugging sessions. Each has a distinct signature and a one-pass fix.

Symptom 1: SELECT returns empty silently

Signature: await supabase.from('notes').select() returns { data: [], error: null }. The user is logged in. Rows exist when you check via the SQL editor (as postgres).

Root cause: The policy's USING clause evaluates to false for every row. Common reasons: the policy compares user_id = auth.uid() but auth.uid() returns null because the client was initialized with the anon key without a session attached, or the rows were inserted under a different user_id than the one the user is querying as.

Fix: Run the role-switching procedure from the previous section. Confirm auth.uid() returns the expected UUID. If it returns null, the SSR client is not receiving the session cookies; check the @supabase/ssr setup. If it returns the right UUID and the query still empty, run select user_id from notes (as postgres) and confirm the rows were inserted with that UUID.

Symptom 2: "permission denied for table X"

Signature: Error code 42501, message permission denied for table notes.

Root cause: This is NOT an RLS failure. It is a GRANT failure. The role does not have SELECT permission on the table at all, so RLS never gets to evaluate. Most often this happens when a migration created a table but forgot grant select on public.notes to authenticated, anon;.

Fix: grant select, insert, update, delete on public.notes to authenticated; for the operations you want to expose. Then layer RLS on top to filter rows. RLS without grants is a closed door; grants without RLS is an open one.

Symptom 3: SELECT works but INSERT fails

Signature: Reads succeed. Writes fail with new row violates row-level security policy for table "notes".

Root cause: INSERT policies need WITH CHECK, not USING. A policy that has USING (auth.uid() = user_id) FOR INSERT is silently broken because Postgres has nothing to evaluate the incoming row against. Either the policy was copy-pasted from a SELECT policy without swapping the clause, or the application is trying to insert a row with a user_id that does not match the current user.

Fix: Use WITH CHECK ((select auth.uid()) = user_id) on INSERT policies. For UPDATE policies, provide both USING (which rows can be updated) and WITH CHECK (what the row may become); omitting WITH CHECK on UPDATE lets users rewrite their row's user_id to any value.

Symptom 4: Query takes 11+ seconds under load

Signature: Worked fine with 100 rows. Crawls to 11s+ once the table hits ~100k rows. EXPLAIN shows the policy expression in the per-row filter.

Root cause: auth.uid() is being called once per row scanned. Without the subquery wrapper, Postgres cannot determine that the function returns a constant value for the duration of the query, so it re-evaluates per row [1].

Fix: Wrap every auth.uid() and auth.jwt() call in a subquery: (select auth.uid()), (select auth.jwt() ->> 'role'). Supabase's official benchmark on this exact pattern: 179ms to 9ms on a single-table policy, 11,000ms to 7ms when the policy calls a function that joins another table, and 178,000ms to 12ms when the same is true of a security definer function call [1]. The wrapper triggers an InitPlan in the query planner that caches the function result for the whole query [1].

Symptom 5: Works in dev, fails on production

Signature: Identical code, identical policy, identical user. Dev returns rows; prod returns empty or errors.

Root cause: One of three things. First, RLS is enabled on dev but disabled on prod (or vice versa), usually because a backup restore did not carry the alter table ... enable row level security statement. Second, a Custom Access Token Hook injects an app_metadata.role claim that exists on the dev project but was never configured on prod. Third, the dev client uses the service_role key (bypasses RLS) while prod uses the anon key.

Fix: Check relrowsecurity in pg_class on both projects. Check the JWT contents from a production session (paste the access token into the JWT decoder) to confirm the expected claims are present. Confirm the production client is initialized with NEXT_PUBLIC_SUPABASE_ANON_KEY, not the service role key. The third cause is the single most common one.

Symptom 6: Accidental service_role bypass

Signature: RLS is enabled, policies are written, queries succeed even when they should not. A user can read another user's rows.

Root cause: The client is using the service_role key, which bypasses RLS by design. Most often this happens because the same createClient() import is used for both Server Actions (where service_role is appropriate) and client components (where it is a critical leak). Or because a process.env.SUPABASE_SERVICE_ROLE_KEY was accidentally exposed via NEXT_PUBLIC_ prefixing.

Fix: Split the clients explicitly: createServerClient from @supabase/ssr for SSR with cookies, createBrowserClient for client components (both use the anon key), and a separate createAdminClient() factory that uses service_role only inside Server Actions. Search the codebase for service_role and confirm every match is server-side. If the leak shipped to production, rotate the key in the Supabase dashboard. This is OWASP A01 (Broken Access Control) and is the exact pattern behind the 170+ vibe-coded apps that got hacked in early 2026; the OWASP Top 10 mapping walks the full defense chain.

Symptom 7: Recursive policy infinite loop

Signature: Query errors out with stack depth limit exceeded. No mention of RLS in the message.

Root cause: A policy on table A calls a security invoker function that itself queries table A. The function call re-enters the policy, which re-calls the function, which re-enters the policy. Postgres detects the stack overflow after a few thousand iterations rather than as an RLS-specific error.

Fix: Make the helper function security definer so it runs with the owner's privileges and bypasses the caller's policies on its internal queries. Add set search_path = public and revoke execute on function helper_fn from public, anon to close the hijack surface. The pattern is documented in the pillar's Pattern 2 multi-tenant section.

Building this from scratch on a new SaaS?

SecureStartKit ships every pattern in this post out of the box: backend-only data access, Zod on every Server Action, RLS deny-all, signed Stripe webhooks with idempotency dedup. One purchase, lifetime updates.

See what's included →Live demo

How do you use EXPLAIN ANALYZE to find per-row auth.uid() calls?

EXPLAIN ANALYZE reveals whether a function call is cached once or evaluated per row. Run it inside the same role-switched transaction so the policy actually applies:

begin;
set local role authenticated;
set local request.jwt.claims = '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated"}';

explain analyze select id, title from public.notes;

A policy that calls auth.uid() directly (unwrapped) produces an output like this:

Seq Scan on notes  (cost=0.00..2543.00 rows=1 width=68) (actual time=11532.123..11532.124 rows=4 loops=1)
  Filter: (user_id = auth.uid())
  Rows Removed by Filter: 99996
Planning Time: 0.187 ms
Execution Time: 11532.456 ms

The Filter: (user_id = auth.uid()) line is the smoking gun. The function is being called once per row evaluated, which means Rows Removed by Filter: 99996 cost 99,996 function calls on top of the seq scan.

The wrapped version (select auth.uid()) produces a different plan:

Seq Scan on notes  (cost=0.01..2543.01 rows=1 width=68) (actual time=8.234..8.567 rows=4 loops=1)
  Filter: (user_id = $0)
  Rows Removed by Filter: 99996
  InitPlan 1 (returns $0)
    ->  Result  (cost=0.00..0.01 rows=1 width=16) (actual time=0.012..0.013 rows=1 loops=1)
Planning Time: 0.234 ms
Execution Time: 8.789 ms

The InitPlan 1 (returns $0) line is the cache: the planner computed auth.uid() once, stored the result as $0, and the per-row Filter now compares against a constant instead of re-invoking the function [1]. Same Seq Scan, same rows removed, three orders of magnitude faster.

Once you can read those two plans side by side, the broader audit becomes mechanical. Any policy on a table that will grow past 10k rows needs the wrapper. Any column referenced in the USING clause needs an index (the pillar's RLS performance section covers indexing strategy). And any policy that joins another table needs a security definer helper function so the join lives behind one InitPlan instead of running per row [1].

The debugging session that taught me to wrap auth.uid()

We had a notes table policy that worked perfectly in staging. The seeded data was small. Every query returned in under 20ms. Then a customer with ~120k notes hit a list view in production and the request timed out at 30 seconds. The Vercel function log showed Supabase returning a 504 from the Postgres connection, which I read as a transient network issue and re-deployed.

It happened again on the next request. I pulled the query out of the Server Action and ran it directly:

-- as postgres in the SQL editor (so RLS is bypassed)
select count(*) from notes where user_id = 'the-customer-uuid';
-- 124,381 in 14ms

The data was fine. The policy was suspect, so I ran the role-switched version:

begin;
set local role authenticated;
set local request.jwt.claims = '{"sub":"the-customer-uuid","role":"authenticated"}';
explain analyze select id from notes;
rollback;

The plan came back with Filter: (user_id = auth.uid()) and Execution Time: 11843 ms. Same table, same rows, same user, but 1000x slower because the Filter line told me auth.uid() was being called once per row. The policy had been written as using (auth.uid() = user_id) without the subquery wrapper.

The fix was one line:

alter policy "notes_select_own"
on public.notes
to authenticated
using ((select auth.uid()) = user_id);

Re-running EXPLAIN ANALYZE showed the InitPlan line, the filter changed to Filter: (user_id = $0), and the execution time dropped to 9ms. We deployed the migration, the customer's list view rendered in 200ms end-to-end, and I wrote a one-paragraph note to the team about why every new policy must use the wrapper.

The lesson that stuck: the policy was technically correct. Every test that mattered (security tests on small data, manual checks, type-checked code) passed. The only thing that surfaced the bug was a customer with enough rows to make per-row function evaluation visible, and the only diagnostic that named the actual cause was EXPLAIN ANALYZE inside a role-switched transaction.

When debugging RLS is a sign you need a different architecture

RLS is a powerful primitive, and the seven symptoms above are tractable once you have the diagnostic procedure. But there is a point where the policy sprawl becomes its own failure mode. Every table is four policies (SELECT, INSERT, UPDATE, DELETE). Every refactor of the access model means auditing every policy that references the changed columns. At some point an engineer in a hurry writes using (true) on a table "just to unblock a deploy."

The architectural alternative is to treat the database as private and route every read and write through Server Actions that use createAdminClient() (the service_role key) and validate authorization in TypeScript. The backend-only data access pattern is the explicit version of this trade-off: you give up RLS as the perimeter and gain a single audited code path for every query. The pattern is one of the five architectural defaults SecureStartKit ships with, alongside Row Level Security for tables that genuinely need it.

The two approaches compose. RLS as defense-in-depth on tables where backend-only is too rigid; backend-only as the perimeter for everything that can route through a Server Action. The choice is per-table, not per-project. But the diagnostic procedure in this guide applies regardless: when a policy is deployed and not behaving the way you expected, the answer is almost always one of the seven symptoms, and EXPLAIN ANALYZE inside a role-switched transaction names which one. For the broader pre-deploy review that catches policy gaps before they hit production, the SaaS Security Checklist covers RLS alongside the other database and auth checks. The free RLS Policy Generator emits the known-good wrapper pattern by default for new policies.

Built for developers who care about security

SecureStartKit ships with these patterns out of the box.

Backend-only data access, Zod validation on every input, RLS enabled, Stripe webhooks verified. One purchase, lifetime updates.

View PricingSee the template in action

References

  1. RLS Performance and Best Practices— github.com
  2. Row Level Security, Supabase Docs— supabase.com
  3. Authentication, PostgREST Documentation— postgrest.org

Related Posts

Apr 15, 2026·Tutorial

Supabase RLS Policies That Actually Work [2026 Guide]

Most Supabase RLS tutorials stop at 'enable RLS.' Here's how to write policies for ownership, multi-tenant access, admin roles, and fast queries.

Apr 28, 2026·Tutorial

Secure File Uploads in Next.js + Supabase Storage [2026]

Most Supabase upload tutorials skip RLS on the bucket and trust the client. Here's how to upload securely in Next.js with Server Actions, signed URLs, and validation.

Mar 20, 2026·Tutorial

Next.js proxy.ts Auth: Protect Routes with Supabase

Next.js 16 renamed middleware.ts to proxy.ts. Here's how to migrate your Supabase route protection and understand what actually changed.