Originally published on cenkkurtoglu.com.
Your policy looks right. It works in the SQL editor. But from a server route the same query returns an empty array, or fails with 42501 permission denied — for a user who is clearly logged in. Nine times out of ten the policy is fine and the request is the problem: it never arrived authenticated, so auth.uid() is null and every ownership rule filters the rows away.
First, tell the two failures apart
-
Empty result set (no error): RLS evaluated your policy to false for every row. Usually
auth.uid()is null, soauth.uid() = user_idmatches nothing. -
42501 permission denied for table: this is not RLS at all. It is a missing table-levelGRANTfor the role the request runs as — most often because the table lives in a custom schema (e.g.api) that theauthenticatedrole was never granted access to.
Confirm which role the request runs as
Run this inside the exact server call that is failing:
select auth.role() as role, auth.uid() as uid;
If role is anon and uid is null, the user's token never reached PostgREST. That is the real bug.
Fix 1: pass the user's token to the server client
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export async function getServerSupabase() {
const cookieStore = await cookies()
return createServerClient(URL, ANON_KEY, {
cookies: {
getAll: () => cookieStore.getAll(),
setAll: (list) => list.forEach(c => cookieStore.set(c.name, c.value, c.options)),
},
})
}
Then re-run the check: auth.role() should be authenticated and auth.uid() non-null.
Fix 2: using Clerk (or another third-party auth)?
First, supabase.auth.getUser() returns null and no user appears in auth.users — that is expected, because the session belongs to Clerk, not Supabase Auth. Configure Clerk as a third-party auth provider and attach its token:
createClient(URL, ANON_KEY, {
accessToken: async () => (await getToken()) ?? null,
})
Second, auth.uid() casts the JWT sub claim to a UUID, but Clerk's sub looks like user_2ab... and is not a UUID — so auth.uid() comes back null even with a valid token. In Clerk-backed policies, compare the raw claim and store Clerk's id as text:
create policy "own rows" on public.your_table
for all to authenticated
using ( (auth.jwt() ->> 'sub') = user_id );
Fix 3: a 42501 with a custom schema
Supabase auto-grants on public, but not on a schema you created. If your table lives in api:
grant usage on schema api to authenticated;
grant select, insert, update, delete on all tables in schema api to authenticated;
alter default privileges in schema api grant select, insert, update, delete on tables to authenticated;
Grants decide whether the query is allowed to run at all; RLS then decides which rows come back. A 42501 means you never cleared the first gate.
Want the full version — 60 checks plus a role-simulation harness that queries your database as anon and as a second user to prove isolation? Supabase RLS Audit Kit ($29). Free read-only demo (runs in ~2s, no Docker): github.com/cekuu35/supabase-rls-leak-demo.
Top comments (0)