Supabase RLS Policy Not Working: 2026 Debug Checklist
Developer Guide

Supabase RLS Policy Not Working: 2026 Debug Checklist

Supabase RLS policy not working? Find auth.uid nulls, uuid/text mismatches, USING vs WITH CHECK bugs, role leaks, service key traps, and safe fixes today.

Updated
12 min read
Supabase RLS Policy Not Working: 2026 Debug Checklist

Photo by Sasun Bughdaryan on Unsplash

Introduction#

When a Supabase RLS policy is not working, do not start by rewriting the policy. Start by proving three facts in order: RLS is enabled on the table (pg_tables.rowsecurity = true), the request arrives as the role your policy targets (anon, authenticated, or a service key that bypasses RLS entirely), and auth.uid() returns a value whose type matches your ownership column. Nine times out of ten the policy SQL was never the problem — one of those three preconditions was.

The checklist below runs those proofs in the order that eliminates the most causes fastest, then covers the policy-level mistakes (USING vs WITH CHECK, additive permissive policies, Storage's separate storage.objects table) and finishes with the performance patterns Supabase documents for policies that are correct but slow.

For deeper policy design, pair this with Supabase RLS policy design patterns, debugging Supabase RLS issues, and the service role key guide for Next.js.

Real Reports This Checklist Is Based On#

1. Confirm RLS Is Enabled On The Table#

This sounds obvious, but it catches two opposite bugs: testing a policy that is not active, and expecting a policy to protect a table where RLS was never enabled.

Run:

sql
select
  schemaname,
  tablename,
  rowsecurity
from pg_tables
where schemaname = 'public'
order by tablename;

For one table:

sql
select *
from pg_tables
where schemaname = 'public'
  and tablename = 'projects'
  and rowsecurity = true;

If no row returns, enable it:

sql
alter table public.projects enable row level security;
The Fix

The fix in one line: verify rowsecurity = true before debugging policy SQL.

2. Check auth.uid() Type Against Your Column#

Supabase Auth user IDs are UUIDs. Many RLS bugs happen because the table stores a text ID from Auth0, Clerk, Stripe, or a custom user table, then the policy compares it directly to auth.uid().

sql
-- This only works when owner_id is uuid.
create policy "owners can read"
on public.projects
for select
to authenticated
using (auth.uid() = owner_id);

If owner_id is text, cast intentionally:

sql
create policy "owners can read"
on public.projects
for select
to authenticated
using (auth.uid()::text = owner_id);

If owner_id is UUID, keep it UUID. Do not make it text just to make a policy pass. The cleanest schema for Supabase Auth is:

sql
owner_id uuid not null references auth.users(id)
Don't
using (auth.uid() = owner_id_text)
Do
using (auth.uid()::text = owner_id_text) or change the column to uuid

3. Separate USING From WITH CHECK#

This is the policy mistake that creates the most confusing symptoms.

USING controls which existing rows are visible or targetable for select, update, and delete.

WITH CHECK controls which new row values are allowed for insert and update.

For inserts, this is not enough:

sql
create policy "users can insert projects"
on public.projects
for insert
to authenticated
using (auth.uid() = owner_id);

Use with check:

sql
create policy "users can insert projects"
on public.projects
for insert
to authenticated
with check (auth.uid() = owner_id);

For updates, you often need both:

sql
create policy "users can update own projects"
on public.projects
for update
to authenticated
using (auth.uid() = owner_id)
with check (auth.uid() = owner_id);

The first expression says "you may target rows you already own." The second says "you may not update the row into belonging to someone else."

One documented subtlety softens this for updates: per the Supabase RLS guide, "if no with check expression is defined, then the using expression will be used both to determine which rows are visible... and which new rows will be allowed." So an update policy with only USING still validates the new row against that same expression. Where this bites is the asymmetric case — when the two conditions should differ (for example, moderators may edit any row but must not reassign ownership), omitting WITH CHECK silently applies the wrong rule to the written values. Inserts have no such fallback: an insert policy needs an explicit WITH CHECK.

4. Confirm The Request Role Is Really authenticated#

The dashboard SQL editor can lie to your intuition because you are not the same role as the browser client. Your app usually arrives as anon before login and authenticated after login. Server code using a service key arrives as a privileged role and bypasses normal RLS.

Check your client:

ts
const { data: { session } } = await supabase.auth.getSession();
 
console.log({
  hasSession: Boolean(session),
  userId: session?.user?.id,
  role: session?.user?.role,
});

Check your policies:

sql
select
  schemaname,
  tablename,
  policyname,
  roles,
  cmd,
  qual,
  with_check
from pg_policies
where schemaname = 'public'
  and tablename = 'projects';

If your policy says to authenticated but your app is still unauthenticated, RLS is doing the correct thing.

5. Test With A Simulated Role And JWT Claims#

For local debugging, reproduce the app context in SQL. The exact claim shape depends on your Supabase version and JWT setup, but this pattern is the goal:

sql
begin;
 
set local role authenticated;
set local request.jwt.claims = '{
  "sub": "00000000-0000-0000-0000-000000000001",
  "role": "authenticated",
  "aud": "authenticated"
}';
 
select auth.uid();
 
select *
from public.projects;
 
rollback;

If auth.uid() is null here, your claim shape or local setup is wrong. If auth.uid() returns the expected UUID but the table returns nothing, the policy expression is wrong. That distinction saves hours.

When using custom JWTs, remember that PostgREST and Realtime may not behave identically if the token is not passed to both clients. That shows up in realtime failures too; see Supabase Realtime gotchas.

If your policy reads claims directly with auth.jwt(), check which metadata field it reads. Supabase's RLS guide is explicit about the security difference: raw_user_meta_data "can be updated by the authenticated user" and "is not a good place to store authorization data", while raw_app_meta_data "cannot be updated by the user, so it's a good place to store authorization data." A policy like auth.jwt() -> 'user_metadata' ->> 'role' = 'admin' is not a broken policy — it is a working privilege-escalation path, because any user can write that claim into their own metadata.

6. Watch For Inherited Role Confusion#

Policies are additive. Multiple permissive policies can combine in a way that surprises you, especially when you have broad select policies and narrow insert policies.

Audit all policies on the table:

sql
select policyname, permissive, roles, cmd, qual, with_check
from pg_policies
where schemaname = 'public'
  and tablename = 'projects'
order by policyname;

Then test one operation at a time:

ts
await supabase.from("projects").select("id").limit(1);
await supabase.from("projects").insert({ name: "Test", owner_id: user.id });
await supabase.from("projects").update({ name: "Renamed" }).eq("id", projectId);

Do not debug upsert first. upsert can require insert and update permissions, so it hides which half failed.

A special case of policy-on-policy confusion is the policy that queries its own table (or a membership table whose policies query back). That produces the infinite recursion detected in policy for relation error, which has its own dedicated fix path — see Supabase RLS infinite recursion: the exact fix. The short version: move the self-referencing lookup into a security definer function so the inner query is not re-filtered by the same policy.

7. Avoid The service_role Bypass Trap#

The service-role key bypasses RLS. That is useful for trusted server jobs and dangerous everywhere else. If a policy "works" only when you use a service key, your policy is not working; you are bypassing it.

Never expose a service key to the browser:

ts
// Bad: this can leak privileged access to every visitor.
createClient(url, process.env.NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY);

Use the publishable/anon key in client components and keep service-role operations in route handlers, server actions, cron jobs, or background workers.

ts
// Browser client
createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
);

8. Debug Storage Policies As Their Own Table#

Supabase Storage RLS is still RLS, but the table is storage.objects, not your app table. A common mistake is proving that public.admin.id = auth.uid() works in SQL, then assuming an upload policy must pass. The upload still needs a policy on storage.objects and the request must be evaluated as the role you expect.

Start with a policy that checks only the bucket and role:

sql
create policy "authenticated users can upload avatars"
on storage.objects
for insert
to authenticated
with check (bucket_id = 'avatars');

Then add ownership:

sql
create policy "users can upload own avatar"
on storage.objects
for insert
to authenticated
with check (
  bucket_id = 'avatars'
  and name = auth.uid()::text || '.jpg'
);

If this fails, log the authenticated user from the client before upload:

ts
const { data: { user } } = await supabase.auth.getUser();
console.log({ userId: user?.id });

Do not debug Storage by changing policies on public.profiles. Storage inserts do not use that table unless your policy explicitly queries it. The upload-specific variant of the 42501 error — new row violates row-level security policy for table "objects" — has its own walkthrough in Supabase Storage: new row violates RLS policy on storage.objects.

9. Read The Error Shape, Not Only The Status Code#

Supabase can return a 401, 403, or 400 around the same underlying policy failure depending on the product surface. The useful part is usually the Postgres code and message:

txt
code: "42501"
message: "new row violates row-level security policy"

That means the request reached Postgres and the policy rejected it. It is different from a network error, missing table, bad API key, or malformed JWT. Save the full error object while debugging:

ts
const { data, error } = await supabase.from("projects").insert(payload).select();
 
console.log(JSON.stringify({ data, error }, null, 2));

Once you know the exact operation and policy command, the fix becomes mechanical: select needs USING, insert needs WITH CHECK, and update often needs both.

10. When The Policy Is Correct But Queries Crawl#

"Not working" sometimes means "times out on real data." A policy runs as an implicit WHERE clause on every row the query touches, and Supabase's RLS guide documents concrete patterns for making that cheap — with benchmark numbers:

Wrap helper functions in a select subquery. using ((select auth.uid()) = owner_id) instead of using (auth.uid() = owner_id). Per the docs, the wrap causes "an initPlan to be run by the Postgres optimizer, which allows it to 'cache' the results per-statement" rather than re-evaluating the function per row — their benchmark drops from 179ms to 9ms (94.97% faster). Only do this for functions like auth.uid()/auth.jwt(), not for expressions on the row's own columns.

Index the columns your policies filter on. owner_id in a using (auth.uid() = owner_id) policy needs an index exactly as it would in a hand-written WHERE.

Always specify TO authenticated (or the precise role). Without a TO clause the policy expression is evaluated even for roles that can never pass it. The docs' benchmark on this single change: 170ms down to under 0.1ms (99.78%).

Move cross-table lookups into security definer functions. A policy that joins to a team_members table re-runs that table's own RLS on every evaluation; a security definer helper bypasses RLS on the lookup table once. Prefer IN/ANY subqueries over row-by-row joins in the expression itself.

Repeat the policy's filter in the client query. Even though RLS filters implicitly, adding the explicit .eq('owner_id', user.id) to the query gives the planner something to optimize against — the docs measure a 94.74% improvement from this alone.

For designing policies that stay fast as tables grow, the companion guide is Supabase RLS policy design patterns.

When this won't work
  • If you test only with the dashboard SQL editor, you are not testing the browser role.
  • If your app uses a leaked service key, RLS results are meaningless.
  • If you use custom JWTs, verify sub, role, and aud claims before blaming the policy.

Summary#

  • Confirm pg_tables.rowsecurity = true.
  • Match auth.uid() UUID output to your column type.
  • Use USING for row visibility and WITH CHECK for inserted or updated values.
  • Simulate authenticated and JWT claims in SQL before changing app code.
  • Debug Storage policies on storage.objects, not only your app tables.
  • Never use service_role in the browser; it bypasses the policy you are testing.
  • Store authorization data in app_metadata, never user_metadata — users can edit the latter.
  • For slow-but-correct policies: (select auth.uid()), indexes on policy columns, and an explicit TO role are the three documented big wins.

One email a month — no fluff

RLS gotchas, Next.js cache debugging, and the one Supabase setting that bit me last month.