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. (One exception worth ruling out: if this started right after you migrated to asymmetric JWT signing keys, the token is arriving — the Data API just can't resolve it and falls back to anon.) (If auth.uid() comes back non-null but the policy still returns nothing, that's a different bug — usually a uuid-vs-text type mismatch between auth.uid() and your user_id column.) 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. - Server client not wired to cookies (Next.js). In an App Router Server Component or Route Handler, using the browser client — or an
@supabase/ssrserver client that never reads the request cookies — means no JWT reaches Postgres, soauth.uid()is NULL. See why getUser() returns null in Next.js server code. - 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.
What does auth.uid() return?
A uuid, or NULL. Nothing else.
The signature is fixed: the function lives in the auth schema, takes no arguments, is language sql, and is declared returns uuid. It is also declared stable, not immutable — Postgres guarantees only that it returns "the same results given the same arguments for all rows within a single statement," and the result is free to change between statements. That is exactly right for a value that depends on the current request.
Because the return type is declared rather than inferred from the value, you get a one-line answer that holds in any context:
select pg_typeof(auth.uid()); -- uuid, even when the value is null
Supabase describes the value as "the ID of the user making the request" — the same uuid as that user's id in auth.users. It reaches the database as the token's sub claim, which the JWT fields reference documents as a string holding "The user ID (UUID)". So auth.uid() is not a lookup against auth.users; it is a uuid cast of one claim off the token on the current request.
Two things it is not. There is no argument-taking overload — auth.uid(some_id) does not exist, and \df auth.uid in psql prints the one zero-argument signature. And it never hands your policy a text value: the cast to uuid happens inside the function body, before the comparison in your policy ever runs.
Is auth.uid() supposed to be NULL when the user isn't logged in?
Yes. It is the documented, intended result — not a bug, and not a broken pipeline. The RLS guide states it directly: when a request is made without an authenticated user, because no access token was provided or the session expired, "auth.uid() returns null."
A signed-out request producing NULL is the system correctly reporting that nobody is logged in. The diagnostics above tell you the JWT never arrived; only you can say whether it was supposed to. If the user is genuinely signed out, there is nothing to fix in the policy.
The same NULL fails differently depending on the clause, which is why one signed-out session can surface as two unrelated-looking symptoms. Postgres is explicit about the asymmetry: under USING, non-matching rows are "silently suppressed; no error is reported," while under WITH CHECK, "An error will be thrown if the expression evaluates to false or null." NULL is a silent filter on reads and a hard failure on writes — same policy, same NULL, different outcome.
Supabase recommends stating the intent explicitly rather than leaning on NULL comparison semantics to do the excluding: USING (auth.uid() IS NOT NULL AND auth.uid() = user_id). Treat that as a readability guard layered on top of the TO authenticated scoping above, not a substitute for it — the role clause is what actually stops the predicate from running for anon.
Does auth.uid() return NULL for anonymous sign-ins?
No. An "anonymous user" in Supabase Auth is not the anon role. signInAnonymously() creates a real row in auth.users, and — like permanent users — that user reaches the Data API as the authenticated Postgres role. So auth.uid() returns a real uuid, exactly as it does for any other signed-in user.
Which means a NULL test can never separate an anonymous user from a permanent one. It only separates "no token" from "some token." Supabase's documented discriminator is the is_anonymous claim, a boolean in the JWT read via auth.jwt():
create policy "Permanent users can insert posts"
on posts for insert
to authenticated
with check ( (auth.jwt() ->> 'is_anonymous')::boolean is false );
Use is false rather than = false so a missing claim evaluates to false and denies, instead of yielding NULL. And note the role clause does nothing to filter here: anonymous users are authenticated, so to authenticated lets them straight through to the check.
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.
Does this apply if I use Clerk (or another third-party auth provider)?
Yes, with a twist. With Clerk, auth.uid() fails for a different reason: Clerk user IDs (user_2abc…) aren't UUIDs, so casting the sub claim to uuid throws or returns NULL. The fix is Supabase's native third-party-auth integration plus policies that compare auth.jwt() ->> 'sub' to a text column — see Clerk + Supabase RLS: why auth.uid() is null.
Catch this before it ships — free
GuardLayer scans every push for this and 28 other Next.js + Supabase issues, with the exact fix inline.
No signup, no card — your code is scanned in memory and never stored.