Stop the Supabase getSession() Security Warning

The Supabase "using getSession() is insecure" warning floods your logs. Here is why getUser() fixes it and where getSession() stays safe.

Mahdi Ben RhoumaMahdi Ben Rhouma · Updated

What actually changed#

You upgraded @supabase/auth-js to v2 (or pulled @supabase/ssr into your Next.js App Router project), and now your terminal fills up with this on every protected route:

plaintext
Using the user object as returned from supabase.auth.getSession() or
from some supabase.auth.onAuthStateChange() events could be insecure if the
user object is used for authorization purposes. This is because the user
object is not validated on the server and can be tampered with.
Use supabase.auth.getUser() instead.

It is not a thrown error — your app keeps working — so most teams ignore it for weeks. Then a security review flags it, or a forged-cookie attack vector shows up in a pentest, and the warning becomes a P0. The issue has been the most-commented auth-js issue for over a year (supabase/auth-js#873).

The fix#

The warning is telling you exactly what to do, but not where. The rule:

  • Use getUser() for any decision that grants or denies access. Middleware, Server Component gating, Server Actions, Route Handlers.
  • Use getSession() only for optimistic client UI — rendering a username before the server has confirmed the session.

Before — the code that triggers the warning#

typescript
// src/middleware.ts (or any Server Component)
import { createServerClient } from '@supabase/ssr'
 
const supabase = createServerClient(url, anonKey, { cookies })
 
// ❌ Reads the cookie, does NOT validate the JWT with the Auth server.
const { data: { session } } = await supabase.auth.getSession()
 
if (!session) {
  return NextResponse.redirect(loginUrl) // gated on unvalidated data
}

getSession() returns whatever is in the cookie. If an attacker crafts a cookie with a fake JWT, getSession() happily hands back a session object and your redirect never fires — or worse, your protected data leaks.

After — validate server-side#

typescript
// src/middleware.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
 
export async function middleware(request: NextRequest) {
  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll: () => request.cookies.getAll(),
        setAll: (toSet) =>
          toSet.forEach(({ name, value, options }) =>
            request.cookies.set(name, value),
          ),
      },
    },
  )
 
  // ✅ Validates the JWT against the Supabase Auth server.
  const { data: { user } } = await supabase.auth.getUser()
 
  if (!user && request.nextUrl.pathname.startsWith('/dashboard')) {
    return NextResponse.redirect(new URL('/login', request.url))
  }
 
  return NextResponse.next()
}

The same swap applies to Server Components and Server Actions — replace getSession() with getUser() and destructure user, not session. The user object is what you gate on; the session is for the client.

Where getSession() is still correct#

tsx
'use client'
 
import { createClient } from '@/lib/supabase/client'
 
export function HeaderAvatar() {
  const supabase = createClient()
  const [email, setEmail] = useState<string | null>(null)
 
  useEffect(() => {
    // ✅ Optimistic UI — fine. Not used for authorization.
    supabase.auth.getSession().then(({ data: { session } }) => {
      setEmail(session?.user?.email ?? null)
    })
  }, [])
 
  return <span>{email ?? 'Not signed in'}</span>
}

This is the only place getSession() belongs. If a fake cookie fools it, the worst outcome is a wrong email shown for a second until the real session loads. No data is exposed.

Illustration: the dev-server output before and after the swap. Before, every GET /dashboard prints the auth-js warning that the user object from supabase.auth.getSession() is not validated on the server and could be insecure. After replacing getSession() with getUser(), the same route logs a server-side validated user and no warning. Example only - the warning text is quoted verbatim from supabase/auth-js issue #873.

Verifying the fix#

  1. grep -rn "auth.getSession" src/ — every hit outside a 'use client' file must change to getUser().
  2. Run your dev server and load a protected route. The warning should stop printing.
  3. Forge a cookie manually (set sb-access-token to a random string in DevTools → Application → Cookies) and reload. With getUser(), the route redirects to /login. With the old getSession() code, it would have let you through.

The deeper background on why this distinction matters for the entire request lifecycle — middleware refresh, Server Component reads, Client Component sync — is in the Next.js + Supabase SSR session management deep dive, which walks through the cookie refresh chain that makes getUser() safe without doubling your latency.

The other half of the warning: onAuthStateChange#

The warning text quoted at the top of this fix actually names two sources, not one: "the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events". Swapping getSession() for getUser() in middleware and Server Components fixes the first source. It does nothing for the second, because onAuthStateChange is a client-side listener, not a call you replace — it fires automatically whenever the auth state changes (sign-in, sign-out, token refresh), and the session object it hands your callback comes from the same unvalidated cookie read as getSession().

tsx
'use client'
 
useEffect(() => {
  const { data: { subscription } } = supabase.auth.onAuthStateChange(
    (event, session) => {
      // ❌ session.user here is NOT validated against the Auth server
      setCurrentUser(session?.user ?? null)
    },
  )
  return () => subscription.unsubscribe()
}, [])

If that callback only updates UI state — a header avatar, a "signed in as" label — this is the same safe optimistic-UI case as getSession() above, and no change is needed. The warning starts to matter the moment the callback does anything that gates access: redirecting the user away on a sign-out event, revealing a client-side route, or passing session.user.id into a call that trusts it for authorization. In that case, call getUser() inside the handler before acting on the event, rather than trusting the session argument directly:

tsx
const { data: { subscription } } = supabase.auth.onAuthStateChange(
  async (event) => {
    if (event === 'SIGNED_OUT') {
      router.push('/login')
      return
    }
    // ✅ validate before trusting the identity for anything sensitive
    const { data: { user } } = await supabase.auth.getUser()
    setCurrentUser(user)
  },
)

This distinction is easy to miss because the fix for getSession() (swap the call) does not transfer directly to onAuthStateChange (the listener itself stays, only what you do inside it changes) — a mechanical find-and-replace across the codebase won't catch it, which is why grep -rn "auth.getSession" from the verification steps above won't surface this half of the warning at all; you need a second pass specifically for onAuthStateChange call sites. The same event-driven listener is also the source of a separate, unrelated failure mode — a callback that never resolves because it awaits another Supabase call from inside itself — covered in Supabase Hangs After onAuthStateChange: The Deadlock, worth checking if your listener callback is doing both jobs (validating the user and making other Supabase calls) and appears to freeze rather than just log the warning.

Further reading: Supabase Auth + Middleware: Session Guide for Next.js 15.

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

Related fixes & guides