You wrote a tidy Row Level Security policy, something like auth.uid() = user_id, and now one of two annoying things happens: your query returns no rows at all, or it returns everybody's rows. Both are common, both are fixable, and neither means RLS is broken. It means the policy isn't seeing the identity you think it's seeing.
Let's walk the actual causes, in rough order of how often they bite people, with copy-pasteable SQL you can run against your own project.
First, what auth.uid() actually is
auth.uid() reads the sub claim out of the JWT attached to the current request. No JWT, or a JWT without a user, means auth.uid() returns NULL.
That single fact explains most "no rows" bugs. A policy like auth.uid() = user_id becomes NULL = user_id, which evaluates to NULL (not true), so the row is filtered out. Every row. Silently.
So the first debugging question is never "is my policy wrong?" It's "who does the database think I am right now?"
Cause 1: You're on the anon key, so auth.uid() is NULL
Every Supabase project ships two keys, and they are not interchangeable:
-
anon / publishable (
role: anon) — public by design. It's meant to sit in your browser bundle. It is not a secret and does not need rotating. RLS is what protects your data when this key is used. -
service_role (
role: service_role) — a server-only secret that bypasses RLS entirely. More on why that's dangerous below.
If you initialize the client with the anon key and the user hasn't logged in, there's no user JWT, so auth.uid() is NULL and auth.uid() = user_id matches nothing.
Confirm which key you're holding by decoding the middle segment of the JWT (base64url-encoded JSON):
echo 'PASTE_KEY_HERE' | cut -d. -f2 | base64 -d 2>/dev/null | python3 -m json.tool
Read the role field: "anon" or "service_role". The newer key formats make it obvious without decoding: sb_publishable_... is public, sb_secret_... is the secret one.
Then confirm you're actually authenticated. In the browser:
const { data: { user } } = await supabase.auth.getUser()
console.log(user) // null here means auth.uid() will be NULL server-side
If user is null, fix sign-in first — no policy change helps until the request carries a user JWT.
Cause 2: RLS isn't actually enabled, so you get everything
The opposite symptom — you see all rows — usually means RLS isn't switched on for the table. Policies are ignored entirely until you enable RLS:
select relname, relrowsecurity
from pg_class
where relname = 'your_table';
If relrowsecurity is false, turn it on:
alter table your_table enable row level security;
One gotcha: the table owner and superusers still bypass RLS. To apply the rules even to the owner, add alter table your_table force row level security;.
Cause 3: You're testing with service_role, so it "works" (misleadingly)
The sneakiest one. Your test passes, data comes back exactly right — because the test client is using the service_role key, which bypasses RLS completely. You're not testing your policies; you're testing with the guard switched off.
service_role belongs only on a trusted server. Never in a browser, never in a public repo, never in a client .env that gets bundled. Anything prefixed NEXT_PUBLIC_, VITE_, or EXPO_PUBLIC_ gets inlined into the client bundle at build time — perfect for the anon key and project URL, catastrophic for service_role.
And if service_role has already been committed to a repo: deleting the file is not enough. A committed secret lives in git history forever. Rotate the key in the dashboard and purge history (git filter-repo or BFG). Rotation is the part that actually revokes the leak; history-scrubbing stops the old value from being fetched back out.
Test policies with the anon key plus a real logged-in session, the way your app runs. To check directly in SQL, impersonate a role and a user inside a transaction:
begin;
-- become the anon role
set local role anon;
select * from your_table; -- honors your anon policies
-- now simulate a logged-in user
set local role authenticated;
select set_config(
'request.jwt.claims',
'{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated"}',
true
);
select * from your_table; -- auth.uid() now returns that sub
rollback;
If it returns rows here but not in your app, the difference is your app's auth state, not the policy.
Cause 4: Missing GRANT
RLS policies do not grant access — they restrict it. A role needs both a matching policy and a table-level privilege. If anon or authenticated was never granted SELECT, no policy will save you; the query errors out regardless of the rows.
select grantee, privilege_type
from information_schema.role_table_grants
where table_name = 'your_table';
Supabase's default setup grants the API roles broad privileges, but if you've been locking things down by hand you may have revoked them:
grant select, insert, update, delete on your_table to authenticated;
Then let RLS decide which rows each user actually touches.
Cause 5: Missing (or wrong) TO clause
A policy with no TO clause applies to every role, including anon. That's a frequent source of "why can logged-out visitors read this?"
Scope policies explicitly:
create policy "Users read own rows"
on your_table
for select
to authenticated -- <- this line matters
using ( (select auth.uid()) = user_id );
Multiple permissive policies are OR'd together, so a broad one you forgot about widens access. List what's really on the table:
select policyname, cmd, roles, qual, with_check
from pg_policies
where tablename = 'your_table';
Read the roles column. If you see {public} where you expected {authenticated}, that's your leak — public means every role, anon included.
Cause 6: Comparing a uuid to a text column
auth.uid() returns a uuid. If your user_id column is text, Postgres resolves uuid = text by casting the text to uuid — which throws invalid input syntax for type uuid the moment any value isn't a well-formed uuid, and adds a per-row cast even when it isn't. Check the type:
select column_name, data_type
from information_schema.columns
where table_name = 'your_table' and column_name = 'user_id';
The clean fix is a real uuid column referencing auth.users:
alter table your_table
alter column user_id type uuid using user_id::uuid;
If you can't change the schema right now, cast inside the policy so both sides match:
using ( (select auth.uid())::text = user_id )
Prefer fixing the column type — a uuid that references auth.users(id) indexes better and is harder to get wrong later.
The policy pattern to standardize on
Notice I keep writing (select auth.uid()) instead of bare auth.uid(). Wrapping it in a select lets Postgres evaluate the function once per query instead of once per row — a real, measurable win on larger tables, and the pattern Supabase now recommends. A complete per-user set:
alter table documents enable row level security;
create policy "read own documents"
on documents for select
to authenticated
using ( (select auth.uid()) = user_id );
create policy "insert own documents"
on documents for insert
to authenticated
with check ( (select auth.uid()) = user_id );
create policy "update own documents"
on documents for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );
Mental model for the two clauses: USING filters which existing rows a role can see (and which rows an UPDATE/DELETE may touch); WITH CHECK validates the new values being written. INSERT policies have WITH CHECK only. UPDATE usually wants both — USING to pick the row, WITH CHECK so a user can't reassign user_id to someone else.
A 60-second self-audit
-- tables in public with RLS still disabled
select c.relname
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public'
and c.relkind = 'r'
and c.relrowsecurity = false;
-- policies with no role restriction (they apply to anon too)
select tablename, policyname, cmd, roles
from pg_policies
where schemaname = 'public'
and roles = '{public}';
If you want a reproducible sandbox where each failure mode fires on demand — anon-key NULL, missing GRANT, service_role bypass — plus a fuller read-only audit query to paste into the SQL editor, I put a small demo repo together: github.com/cekuu35/supabase-rls-leak-demo. Clone it, run the SQL, and watch the "no rows / all rows" behavior flip as you toggle each cause. It's the fastest way I know to build the intuition.
The takeaway
When auth.uid() "doesn't work," it's almost never that RLS is broken. It's that the request isn't carrying the identity you assume: you're on the anon key with no session, RLS was never enabled, a test used service_role and hid the bug, a GRANT is missing, the TO clause is too wide, or a uuid is being compared to text. Walk the six checks in order and you'll find it.
If you'd rather not hand-audit every time you ship a new table, I maintain a $29 RLS Audit Kit — ready-to-run policy tests and a checklist that flags exactly these six failure modes across your whole schema. Entirely optional; the queries above will get you unstuck today either way.
Top comments (0)