Why auth.uid() returns NULL in a Supabase RLS policy
TL;DR: auth.uid() returns NULL whenever no user JWT reaches Postgres. The two culprits, in order: you're testing the policy in the SQL editor (which runs as the privileged postgres role with no JWT), or your server/SSR client never forwarded the logged-in user's session. Fix the second by building the server client with @supabase/ssr so it reads the auth cookies and sends Authorization: Bearer <jwt> on every request — the anon key alone is not enough, the JWT is what makes auth.uid() resolve.
Why is auth.uid() returning NULL in my RLS policy?
Because no user JWT reached the database on that request. Under the hood, auth.uid() is essentially:
current_setting('request.jwt.claims', true)::jsonb ->> 'sub'
PostgREST populates that request.jwt.claims session setting only when a request arrives carrying a valid Authorization: Bearer <access_token>. Any code path where that JWT never lands — wrong key, missing header, a non-request context, or a privileged role — leaves the setting empty, so auth.uid() is NULL. And NULL = user_id evaluates to NULL — never true — so your policy quietly denies every row and you get an empty array back with no error.
So this is almost never a broken policy. It's a JWT that never arrived. Confirm which situation you're in by running this in the exact same request path:
select auth.uid(), current_user, current_setting('request.jwt.claims', true);
If current_user is postgres or anon and the claims are empty, the JWT never made it to Postgres.
Why does my query work in the SQL editor but return nothing in my app?
Because the SQL editor connects as the privileged postgres role, which bypasses RLS entirely — so your SELECT returns every row even though auth.uid() is NULL there too (that role has no request.jwt.claims). The result: policies look "fine" at edit time and return nothing for real users hitting them through the API. It's the single most common way people get misled.
To test a policy the way a real user experiences it, impersonate the authenticated role and inject a JWT claim. Wrap it in a transaction so the impersonation can't leak into later editor queries:
begin;
-- Become the authenticated role and inject a fake JWT for one user
set local role authenticated;
set local request.jwt.claims to '{"sub":"5950b438-b07c-4012-8190-6ce79e4bd8e5","role":"authenticated"}';
select auth.uid(); -- sanity check: should print the uuid, not NULL
select * from profiles; -- returns only rows the policy allows
rollback; -- discard role + claims, restore the editor session
Set the role and the sub together — auth.uid() needs sub, and policies scoped to authenticated need the role. One gotcha: the editor's "impersonate user" dropdown does not fire custom access-token / auth hooks, so any claims built by a custom_access_token_hook won't appear. Inject those claims manually in the JSON above, or test the real end-to-end path from a logged-in client.
Fix: forward the user's JWT from the server
If auth.uid() is NULL in server code (Next.js route handlers, Server Components, server actions), your Supabase client isn't carrying the user's session. The fix is the @supabase/ssr server client, which reads the auth cookies on every request and forwards the access token as Authorization: Bearer <jwt>. That token is what makes auth.uid() resolve. Note that RLS is enforced on the Authorization header, not the apikey header — so the anon key by itself proves nothing.
// utils/supabase/server.ts (Next.js App Router)
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export async function createClient() {
const cookieStore = await cookies() // async in Next 15+
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, // anon key — RLS + the user's JWT do the work
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// Called from a Server Component — safe to ignore when
// middleware is refreshing the session.
}
},
},
}
)
}
Use the getAll/setAll cookie interface. The old get/set/remove methods are deprecated and are a frequent reason the token silently fails to attach. With that client, auth.uid() resolves inside the query:
const supabase = await createClient()
const { data } = await supabase.from('profiles').select('*') // JWT forwarded → RLS sees the user
One more server-side trap: don't gate protected pages on getSession(). Supabase is explicit — never trust supabase.auth.getSession() in server code, because it reads the session straight from the cookie without verifying it. Use supabase.auth.getClaims() (or getUser()), which validates the token before you make an auth decision.
Don't "fix" it with the service_role key
Reaching for the service_role key to make queries return data is trading an RLS bug for a security hole. A service_role client always bypasses RLS — your policies never run at all — and it carries no end-user sub, so auth.uid() is still NULL. It only looks fixed because RLS was skipped. Keep service_role strictly for trusted, RLS-exempt server jobs, never behind a user request. The related trap: calling auth.signUp/signInWithPassword on a service_role client returns a user session that overrides the service token, so a client you thought was privileged silently isn't.
If you leak that key into client-side code, RLS stops protecting anything at all. That is exactly the class of mistake GuardLayer's service-role key exposure scan catches.
Fix: scope policies TO authenticated (and add the GRANT)
Anonymous requests run as the anon role with a NULL sub. A policy that relies on auth.uid() but is left open to anon still runs its predicate for every anonymous request — auth.uid() is NULL, so NULL = user_id denies the row, but you've paid to evaluate it and dropped a layer of defense. Supabase's performance guide is blunt: never rely on auth.uid() alone to exclude the anon role — always add authenticated to the policy's roles. Scope each policy TO authenticated so it short-circuits for anon before auth.uid() is ever compared:
alter table profiles enable row level security;
create policy "Users can view their own profile"
on profiles for select
to authenticated -- skipped entirely for anon
using ( (select auth.uid()) = user_id );
create policy "Users can update their own profile"
on profiles for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );
RLS policies and table GRANTs are separate gates: a role needs both the GRANT (table reachable over the Data API) and a passing policy (row visible). Bundle them in the same migration:
grant select, insert, update, delete on public.profiles to authenticated;
The (select auth.uid()) wrapping isn't cosmetic — it lets Postgres evaluate the call once per statement (an initPlan) instead of once per row. If your policy is broad but still returns nothing, the problem may be that it isn't user-scoped at all; see when your RLS policy isn't actually scoped to the user.
Other ways the JWT never arrives
- Query fired before a session exists. Running a query before
signInresolves, before the SDK rehydrates the session from storage, or after the token expired all yield NULL. AssertgetUser()/getClaims()returns a live session immediately before the query. - Session overwritten in a shared client. Calling
signInWithOtp()/signIn()again in a shared client (e.g., an admin creating users) replaces the current session in shared storage with a new unauthenticated one. Use separate clients, orsupabase.auth.admin.createUser(). - Querying through a view. Views run with the view owner's rights by default and bypass the underlying table's RLS unless you set
security_invoker = true(Postgres 15+). - Local / self-hosted lag. Older self-hosted
auth.uid()bodies lack the cloudcoalesce(...)fallback torequest.jwt.claims ->> 'sub'. Diff\sf auth.uidagainst the cloud version and update it. - Non-request contexts.
pg_cronjobs, triggers fired by aservice_rolewrite, and direct DB connections have no PostgREST request, so no JWT GUC andauth.uid()is NULL.
Where GuardLayer fits
Be clear-eyed here: a NULL auth.uid() is usually a runtime problem — the wrong key, a missing header, the SQL editor — and a static scanner can't see your request context, so GuardLayer doesn't claim to catch the null-uid bug itself. What it does catch is the adjacent RLS mistakes that turn a NULL uid() into a silent data leak instead of an empty result: tables with RLS never enabled, policies that were explicitly disabled, and predicates that reference no user identity at all. Those are the failures that bite when the JWT does arrive but the policy doesn't constrain it. Start with the complete guide to Supabase Row Level Security to see how the pieces fit.
FAQ
Why is auth.uid() NULL in the SQL editor?
The editor connects as the privileged postgres role, which has no request.jwt.claims set and bypasses RLS. Impersonate the authenticated role with set local role authenticated; and inject a sub claim via set local request.jwt.claims to test policies realistically.
Does the anon key make auth.uid() work?
No. RLS is enforced on the Authorization: Bearer <jwt> header, not the apikey. The anon key is fine to initialize the client with, but auth.uid() only resolves when the user's access token is also forwarded — which is what the @supabase/ssr server client does via cookies.
Should I use the service_role key to fix a NULL auth.uid()?
No. The service_role key bypasses RLS entirely and carries no user sub, so auth.uid() stays NULL — it only looks fixed because policies stop running. Forward the user's JWT instead, and reserve service_role for trusted server jobs behind no user request.
Why does my INSERT throw error 42501 even though the policy looks correct?
Two common causes. First, auth.uid() is NULL on that request, so the INSERT's WITH CHECK ( auth.uid() = user_id ) fails — fix the JWT forwarding above. Second, an .insert().select() also needs a matching SELECT policy to return the new row; either drop the .select() or add the SELECT policy.
Why is auth.uid() NULL in a database function or cron job?
Because there's no PostgREST request carrying a JWT. pg_cron jobs, triggers from service_role writes, and direct connections all run with no request.jwt.claims. Log current_setting('request.jwt.claims', true) inside the function to confirm the claims are absent.
Why does auth.uid() return NULL only in local/self-hosted dev?
The older self-hosted auth.uid() definition omits the cloud coalesce fallback to request.jwt.claims ->> 'sub', so it returns NULL when only the JSON-claims setting is populated. Diff the function body against the cloud version and update it.
Catch this before it ships — free
GuardLayer scans every push for this and 23 other Next.js + Supabase issues, with the exact fix inline.
No signup, no card — your code is scanned in memory and never stored.