Why supabase.auth.getUser() returns null on the server in Next.js
TL;DR: supabase.auth.getUser() returns null on the server because the Supabase client was never wired to the request cookies, so no user JWT ever reaches the Auth server. The plain browser createClient from @supabase/supabase-js has no access to server cookies — you need @supabase/ssr's createServerClient, initialized from next/headers cookies() with a getAll/setAll interface, plus a middleware that revalidates and refreshes the token on every request. Both files are required. Empty queries and permission denied on the database side are the same missing-JWT bug seen from Postgres.
Why does getUser() return null on the server in Next.js?
Because getUser() needs the user's access token, and on the server that token lives in the request cookies — not in memory. If your Supabase client doesn't read those cookies, it sends the request with no Authorization: Bearer <jwt> header. GoTrue (Supabase's Auth server) sees no token, so getUser() hands back { data: { user: null }, error } where error is an AuthSessionMissingError with the literal message Auth session missing!. Match on that string; don't invent your own.
The single most common cause is importing the wrong client. The browser createClient from @supabase/supabase-js has no cookie interface at all, so in a Server Component or Route Handler it never sees the session and getUser() is always null. The database-side twin of this bug is auth.uid() returning NULL in your RLS policy — RLS-protected SELECTs come back as an empty array with no error. Same root cause, viewed from Postgres.
The fix, part 1: wire the server client to request cookies
Build a cookie-aware server client with @supabase/ssr. This is what forwards the user's JWT on every query:
// utils/supabase/server.ts
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 is correct — the user's JWT + RLS 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, which cannot write cookies.
// Safe to ignore as long as the middleware below refreshes the session.
}
},
},
}
)
}
Then in a Server Component or Route Handler:
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser() // now populated
const { data } = await supabase.from('profiles').select('*') // JWT forwarded -> RLS sees the user
Three details that silently break this if you get them wrong:
- The anon key is correct here. It initializes the client; the user's JWT from the cookie is what authenticates. Do not reach for the
service_rolekey (more on that below). cookies()is async in Next.js 15+. You mustawaitit. Forgetting theawaitthrows at runtime.- Use
getAll/setAll. The older per-cookieget/set/removeinterface is deprecated in@supabase/ssrand is a frequent reason the session silently fails to attach.
The fix, part 2: refresh the session in middleware
The server client alone gets you a working getUser() — until the access token expires (default 3600s). Nothing refreshes the server-side cookie unless a middleware does it on every request. This is the required second half:
// utils/supabase/middleware.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function updateSession(request: NextRequest) {
let supabaseResponse = NextResponse.next({ request })
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value)
)
supabaseResponse = NextResponse.next({ request })
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
)
},
},
}
)
// IMPORTANT: no code between createServerClient and getUser().
// This call revalidates the token and refreshes it if needed.
const { data: { user } } = await supabase.auth.getUser()
if (
!user &&
!request.nextUrl.pathname.startsWith('/login') &&
!request.nextUrl.pathname.startsWith('/auth')
) {
const url = request.nextUrl.clone()
url.pathname = '/login'
return NextResponse.redirect(url)
}
// Return supabaseResponse as-is so the refreshed cookies reach the browser.
return supabaseResponse
}
// middleware.ts (project root)
import { type NextRequest } from 'next/server'
import { updateSession } from '@/utils/supabase/middleware'
export async function middleware(request: NextRequest) {
return await updateSession(request)
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}
Two load-bearing rules from Supabase's own docs, both easy to violate:
- Put no code between
createServerClientand thegetUser()call. Anything in between breaks the refresh timing. - Return the same
supabaseResponseobject (or copy its cookies onto any response you build). Construct a freshNextResponsewithout carrying oversupabaseResponse.cookiesand you discard the refreshed cookies — that shows up as intermittent logouts and server/browser desync.
If your middleware matcher excludes the path being hit, cookies are never refreshed on that route — verify the pattern actually covers your app pages.
getUser() vs getSession() vs getClaims() on the server
These are not interchangeable, and picking the wrong one is a real security bug:
getUser()makes a network round trip to GoTrue to revalidate the token and return the authoritative user record. Use it whenever you need revocation-accurate freshness.getSession()only reads and decodes the cookie locally — it does not verify the JWT. Supabase is explicit: never trustgetSession()for authorization in server code. A forged or stale cookie sails right past it.getClaims()is the newer recommended default for gating pages: it verifies the JWT signature locally against your project's JWKS (asymmetric keys), so it's fast, and falls back togetUser()on legacy HS256.
For any server-side auth decision, use getUser() or getClaims(). If you're seeing a 401 with an invalid-claim message instead of a null user, that's a different diagnosis — see Supabase 401 invalid claim.
Why it works at first, then breaks after an hour
This is the classic "it worked yesterday" report. If you skip the middleware, the initial login cookie is valid, so getUser() returns a user right after sign-in. But nothing refreshes that server-side cookie, so once the default 3600-second access-token expiry passes, getUser() starts returning null and RLS queries begin failing with 401 PGRST301. The middleware above is exactly what prevents this by refreshing the token on every request. If your token is expiring faster than you expect, the JWT expired walkthrough covers the lifecycle.
Common reasons the session still won't attach
- Browser
createClientin server code. No cookie interface, sogetUser()is always null. UsecreateServerClientfrom@supabase/ssr. @supabase/auth-helpers-nextjsmixed in.createServerComponentClient/createRouteHandlerClientare deprecated; the current path is@supabase/ssr. Mixing the old helpers with the new SSR client is a frequent source of this bug.setAllthrowing inside a Server Component is expected, not the bug. Server Components cannot write cookies, so thetry/catchswallows it and the middleware does the actual refresh. Do not "fix" this by deleting thetry/catch.service_rolekey as a workaround. Do not reach for it to makegetUser()or queries return data. It bypasses RLS entirely, carries no usersub, and only looks fixed because your policies stopped running. Forward the user's JWT via the server client instead. Leaking that key is its own critical issue — see service role key exposed.
FAQ
Why does getUser() return null but getSession() returns a user?
getSession() reads the cookie locally without verifying it, so it can return a decoded session even when the token is stale or the client isn't properly wired. getUser() actually calls GoTrue to revalidate. Trust getUser() (or getClaims()) on the server — a getSession() "success" here is not proof of a valid session.
What is the "Auth session missing!" error?
That is the message on the AuthSessionMissingError that getUser() returns when there is genuinely no session — no cookie, or a client with no cookie access. It's a real Supabase string, so you can match on it. If you're seeing it in a Server Component, check that you built the client with createServerClient and awaited cookies().
Do I really need both the server client and the middleware?
Yes. The server client forwards the JWT so getUser() and your queries work on a given request. The middleware refreshes that JWT on every request so it never goes stale. Skip the middleware and getUser() returns null roughly an hour after login.
Should I use the service_role key to make getUser() work?
No. It bypasses RLS and carries no end-user identity, so auth.uid() stays NULL and your policies stop running. It masks the problem instead of fixing it. Wire the anon-key server client to cookies as shown above.
Why is cookies() throwing or returning a promise?
In Next.js 15+, cookies() from next/headers is async. Your server client factory must await cookies(). Forgetting the await is a common runtime error right after upgrading.
Where GuardLayer fits
Be precise about this: getUser() returning null is a runtime problem — a missing or stale session cookie at request time. GuardLayer is a static scanner; it reads your source and SQL migrations, not your live cookies. It cannot see that getUser() returned null, cannot confirm your middleware is actually running in production, and cannot verify your server client is wired to cookies. So GuardLayer does not claim to catch this bug — the fix is the server client plus middleware above, full stop.
What GuardLayer genuinely does catch is the more dangerous authorization mistake right next to this fix. Its supabase/getsession-server-trust rule flags a middleware.ts that calls supabase.auth.getSession() but never calls getUser() or getClaims() to revalidate — the exact pattern where a forged or stale cookie can spoof your auth gate. The rule is scoped to the middleware file, ignores client components, and goes silent the moment the same file also revalidates with getUser()/getClaims(). It will never tell you a token was null at runtime, but it will tell you your server-side auth gate can be fooled by a cookie — the more dangerous cousin of the bug that sent you here.
Catch this before it ships — free
GuardLayer scans every push for this and 24 other Next.js + Supabase issues, with the exact fix inline.
No signup, no card — your code is scanned in memory and never stored.