
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.

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#
- In supabase/supabase issue 41668, the report says: "
auth.uid()returns null" despite bearer token auth. - In supabase/supabase issue 36260, the failure was: "fails/returns NULL" during RLS policy evaluation.
- In Stack Overflow 74525302, the browser error was: "
new row violates row-level security policy". - In supabase/supabase pull request 42346, Supabase described RLS debugging as a major pain point: "
Why can't I see my data?"
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:
select
schemaname,
tablename,
rowsecurity
from pg_tables
where schemaname = 'public'
order by tablename;For one table:
select *
from pg_tables
where schemaname = 'public'
and tablename = 'projects'
and rowsecurity = true;If no row returns, enable it:
alter table public.projects enable row level security;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().
-- 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:
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:
owner_id uuid not null references auth.users(id)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:
create policy "users can insert projects"
on public.projects
for insert
to authenticated
using (auth.uid() = owner_id);Use with check:
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:
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:
const { data: { session } } = await supabase.auth.getSession();
console.log({
hasSession: Boolean(session),
userId: session?.user?.id,
role: session?.user?.role,
});Check your policies:
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:
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:
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:
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:
// 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.
// 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:
create policy "authenticated users can upload avatars"
on storage.objects
for insert
to authenticated
with check (bucket_id = 'avatars');Then add ownership:
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:
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:
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:
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.
- 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, andaudclaims before blaming the policy.
Summary#
- Confirm
pg_tables.rowsecurity = true. - Match
auth.uid()UUID output to your column type. - Use
USINGfor row visibility andWITH CHECKfor inserted or updated values. - Simulate
authenticatedand JWT claims in SQL before changing app code. - Debug Storage policies on
storage.objects, not only your app tables. - Never use
service_rolein 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.
Related Guides

Supabase Auth vs Clerk in 2026: Honest Verdict
Supabase Auth vs Clerk in 2026 with a clear verdict: choose Clerk for complex org auth, Supabase Auth for budget apps using RLS and Postgres-backed data.
Supabase RLS Policy Design Patterns Beyond the Basics
Master advanced Supabase RLS policy patterns for multi-role access, team permissions, and hierarchical authorization. Includes copy-paste SQL and performance tips.
RLS Audit Checklist One Page: Supabase Problem -> Fix
A 30-second, one-page RLS audit checklist for Supabase production systems. — practical, code-backed walkthrough.