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.

2026-06-17
10 min read
Supabase RLS Policy Not Working: 2026 Debug Checklist

Introduction#

When a Supabase RLS policy is not working, do not start by rewriting the policy. Start by proving RLS is enabled, the request is using the role you think it is, and auth.uid() has the same type as your table column.

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."

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.

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.

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.

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.

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.

One email a month — no fluff

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