← All posts
·10 min read·GuardLayer

Supabase "Auth session missing!": getUser() null in Next.js

SupabaseNext.jsAuthSSRMiddleware

TL;DR: supabase.auth.getUser() returns a null user on the server, usually with AuthSessionMissingError: Auth session missing!, because your Supabase client can't read the request's auth cookies, so it has no session to check. The most common cause is the browser createClient from @supabase/supabase-js running in a Server Component or Route Handler. The fix is two files: a server client built with createServerClient from @supabase/ssr that reads await cookies() through getAll/setAll, and a session-refresh file that runs on every request (proxy.ts on Next.js 16, middleware.ts on Next.js 15 and earlier). Skip the second file and sessions never refresh, so users get signed out. Empty results from RLS-protected tables are the same missing-session 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 has no session and no access token at all. supabase-js doesn't even call GoTrue (Supabase's Auth server) in that case: getUser() returns { data: { user: null }, error } straight away, 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 same missing cookie interface also breaks the OAuth callback, where it surfaces as a PKCE code verifier that can't be found.

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_role key (more on that below).
  • cookies() is async in Next.js 15+. You must await it, and make your createClient helper async to do so — the migration that clears the "cookies() should be awaited" warning. Forgetting the await throws at runtime.
  • Use getAll/setAll. The older per-cookie get/set/remove interface is deprecated in @supabase/ssr and is a frequent reason the session silently fails to attach.

The fix, part 2: refresh the session in proxy.ts (or middleware.ts)

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)$).*)',
  ],
}

On Next.js 16, this file is proxy.ts. Next.js 16 deprecated the middleware file convention and renamed it to proxy: rename middleware.ts to proxy.ts and the exported function from middleware to proxy. The body and config.matcher stay the same, and npx @next/codemod@canary middleware-to-proxy . does the rename for you. On Next.js 15 and earlier, keep middleware.ts: a proxy.ts file is never called there, so the session never refreshes. More on the switch in Next.js 16: middleware.ts is now proxy.ts.

Two load-bearing rules from Supabase's own docs, both easy to violate:

  1. Put no code between createServerClient and the auth call. Supabase's current template calls getClaims() at this spot rather than getUser(); either way, anything in between can leave users randomly logged out.
  2. Return the same supabaseResponse object (or copy its cookies onto any response you build). Construct a fresh NextResponse without carrying over supabaseResponse.cookies and 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 trust getSession() 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 to getUser() on legacy HS256.

For any server-side auth decision, use getUser() or getClaims() — the full comparison of getSession vs getUser vs getClaims walks through which to reach for and why getSession() can be spoofed on the server. 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 createClient in server code. No cookie interface, so getUser() is always null. Use createServerClient from @supabase/ssr.
  • @supabase/auth-helpers-nextjs mixed in. createServerComponentClient / createRouteHandlerClient are deprecated; the current path is @supabase/ssr. Mixing the old helpers with the new SSR client is a frequent source of this bug.
  • setAll throwing inside a Server Component is expected, not the bug. Server Components cannot write cookies, so the try/catch swallows it and the middleware does the actual refresh. Do not "fix" this by deleting the try/catch.
  • service_role key as a workaround. Do not reach for it to make getUser() or queries return data. It bypasses RLS entirely, carries no user sub, 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 session has already ended on the server (user signed out or deleted). getUser() actually calls GoTrue to revalidate, and in that case returns the same Auth session missing! error. 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(). If middleware works and only your API routes fail, that's a narrower problem with its own causes — see Auth session missing in Route Handlers.

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 (or Next.js 16 proxy.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 that file (it matches middleware.ts/.js by name, and proxy.ts/.js where Next.js loads it, at the project root or in src/, or as a session helper in a supabase/ folder), 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.

See which of your Supabase tables anyone can read, free

Connect your Supabase project read-only. GuardLayer checks RLS, policies, grants and functions as they are right now, and gives you the SQL to fix each problem. It never reads your rows and can't change anything.

Check my Supabase project

Prefer to check code instead? Scan your repo, no signup.

Keep reading