SecureStartKit
SecurityFeaturesPricingDocsBlogChangelog
Sign inBuy Now
Home/Security/Trusting getSession() Instead of getUser()
CWE-287A07:2025 Authentication FailuresHigh severitySupabaseNext.js

Trusting getSession() Instead of getUser()

✓The kit never trusts getSession() for access: its getUser() helper and middleware both revalidate the token with the Auth server.

Last reviewed June 13, 2026 by SecureStartKit Team

The short answer

getSession() reads the session from the request cookie and decodes the JWT locally, without contacting the Supabase Auth server, so its user object can be stale or spoofed. getUser() sends a request to the Auth server on every call and revalidates the token. For any server-side authorization decision, use getUser() (or getClaims() for a verified, lower-latency check). Treat getSession() as a convenience for reading non-security UI state only.

Where it shows up: A Server Component, Server Action, or route handler calls supabase.auth.getSession() and uses session.user to decide access, trusting a value read straight from the request cookie.

The vulnerable patterns and their fixes

Auth check in a Server Component

✗Vulnerabletsx
// app/(dashboard)/team/page.tsx  (Server Component)
import { createServerClientWithCookies } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'

export default async function TeamPage() {
  const supabase = await createServerClientWithCookies()

  // reads the session straight from the cookie, no Auth-server check
  const { data: { session } } = await supabase.auth.getSession()
  if (!session) redirect('/login')

  // trusting a user object that was never revalidated
  return <TeamRoster ownerId={session.user.id} />
}

getSession() decodes the JWT from the cookie locally. It never contacts the Auth server, so a stale or tampered token is trusted. Supabase documents this directly: do not trust getSession() in server code.

↓the fix
✓Securetsx
// app/(dashboard)/team/page.tsx  (Server Component)
import { getUser } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'

export default async function TeamPage() {
  // getUser() calls the Supabase Auth server and revalidates the JWT
  const user = await getUser()
  if (!user) redirect('/login')

  return <TeamRoster ownerId={user.id} />
}

This is exactly the pattern the kit ships: a getUser() helper in lib/supabase/server.ts that wraps supabase.auth.getUser(), which revalidates the token with the Auth server on every call. The returned id is now safe to use for a query or an access check.

Gating a Server Action

✗Vulnerablets
// app/actions/delete-project.ts  (Server Action)
'use server'
import {
  createServerClientWithCookies,
  createAdminClient,
} from '@/lib/supabase/server'

export async function deleteProject(projectId: string) {
  const supabase = await createServerClientWithCookies()
  const { data: { session } } = await supabase.auth.getSession()
  if (!session) return { error: 'Unauthorized' }

  // destructive action gated on a session the Auth server never confirmed
  await createAdminClient().from('projects').delete().eq('id', projectId)
}

The mutation runs behind a getSession() check, which only proves a cookie was present, not that it is currently valid. A revoked or forged session passes the gate.

↓the fix
✓Securets
// app/actions/delete-project.ts  (Server Action)
'use server'
import { getUser, createAdminClient } from '@/lib/supabase/server'

export async function deleteProject(projectId: string) {
  const user = await getUser() // revalidated against the Auth server
  if (!user) return { error: 'Unauthorized' }

  await createAdminClient()
    .from('projects')
    .delete()
    .eq('id', projectId)
    .eq('owner_id', user.id) // and scope the delete to the real owner
}

getUser() confirms the identity with the Auth server before anything destructive happens, and scoping the delete to user.id closes the ownership gap too. Verifying identity and checking ownership are two separate steps, and you need both.

SecureStartKit ships these defenses by default. RLS, Zod-validated Server Actions, and verified webhooks, already wired in.

Get SecureStartKit→

How it’s exploited

Supabase stores the session, including the access-token JWT, in a cookie. getSession() reads that cookie and decodes the token locally. It does not call the Auth server, so it cannot know whether the token was revoked, whether the user was downgraded, or whether the cookie was tampered with. getUser() makes a round trip to the Auth server, which verifies the token's signature and current validity.

The gap is exploitable two ways. The first is staleness: an admin who is demoted still carries a cookie whose JWT claims the old role until it refreshes. Code that branches on getSession() keeps treating them as an admin during that window. getUser() would reject it immediately because the Auth server knows the current state.

The second is forgery and replay. Because the cookie is client-side storage, its contents are attacker-reachable. A value that was never revalidated against the Auth server is, by definition, untrusted input being used in a security decision. Supabase's own server-side guidance is blunt about this: never trust getSession() inside server code such as middleware, because it is not guaranteed to revalidate the token. The safe call is getUser().

How to find it in your code

Search your server-side code for getSession and audit every hit:

grep -rn "getSession" app lib middleware.ts

Any getSession() whose result feeds an access check, a redirect, or a query scope is a finding. The safe calls are getUser() and getClaims(); getSession() is only acceptable for reading non-security UI state where a stale value is harmless.

Then confirm your actual auth helper revalidates. Open the helper your code calls for the current user and check that it wraps supabase.auth.getUser(), not getSession():

grep -rn "auth.getUser\|auth.getSession" lib/supabase

In this kit the helper is getUser() in lib/supabase/server.ts, and the middleware also calls supabase.auth.getUser(), so the verified path is the default.

Common mistakes

  • Myth“getSession() is faster, so I use it for auth checks and avoid the network call.”

    The speed comes from skipping the revalidation that makes the check trustworthy. getClaims() gives you a verified result without a full round trip on every call; getSession() gives you an unverified one.

  • Myth“It runs on the server, so the cookie it reads is trustworthy.”

    The cookie is client-side storage that the browser sends with the request. Where you read it does not change the fact that its contents are attacker-reachable and were never revalidated.

  • Myth“getSession() returns a user, so it is the same as getUser().”

    Both return a user object, but only getUser() proves it is current. getSession() returns whatever the cookie decodes to, including a stale or revoked session.

  • Myth“My middleware already checked auth, so the page can trust getSession().”

    Middleware should itself use getUser(), and a passing middleware check does not make a later getSession() result valid. Each security decision should rest on a revalidated identity.

Does SecureStartKit prevent this?

SecureStartKit ships the safe path by default. The `getUser()` helper in `lib/supabase/server.ts` wraps `supabase.auth.getUser()`, which revalidates the JWT against the Supabase Auth server, and the middleware calls `supabase.auth.getUser()` too. Nothing in the kit makes an access decision from `getSession()`. The one way to reintroduce the bug is to reach for `getSession()` yourself in new code because it is one call shorter. Keep authorization on `getUser()` (or `getClaims()` when you want a verified check without the full round trip every time), and leave `getSession()` for harmless UI state.

getClaims: verified server-side auth→

Frequently asked questions

What is the difference between getSession() and getUser() in Supabase?
getSession() reads the session from the cookie and decodes the JWT locally without contacting the Auth server, so it can be stale or spoofed. getUser() sends a request to the Auth server every call and revalidates the token, so its result is trustworthy for access decisions.
Is getSession() ever safe to use?
Yes, for non-security purposes such as reading UI state where a stale value does no harm. It is not safe for authorization, redirects that gate protected content, or scoping a query to a user.
What about getClaims()? Is it as safe as getUser()?
getClaims() returns verified claims and is designed to avoid a full Auth-server round trip on every call while still being trustworthy, which makes it a good fit for high-traffic server code. getUser() is the simplest safe default. getSession() is the one to avoid for security decisions.
Does calling getUser() in middleware slow every request down?
It adds a revalidation call, which is the cost of a trustworthy check. The kit accepts that by design. If the latency matters at scale, getClaims() is the verified, lower-overhead option; dropping to getSession() trades away the security, not just the latency.

References

  • Supabase: Server-Side Auth for Next.js ↗
  • Supabase: auth.getUser() reference ↗
  • CWE-287: Improper Authentication (MITRE) ↗
  • OWASP A07:2025 Authentication Failures ↗

Related weaknesses

  • Next.js Middleware Auth BypassAuthorization is implemented only in middleware.ts or proxy.ts, with no second check at the data layer or inside the Server Action, route handler, or page component.
  • IDOR: Missing Ownership CheckA Server Action or route handler reads or writes a record using createAdminClient() with only an id filter and no ownership filter. Because service_role skips Row Level Security, any authenticated user can access any row by supplying an arbitrary id.
  • Open Redirect in Auth CallbackAn auth callback or login route redirects to a user-supplied next or redirectTo parameter without checking that it is a same-origin relative path.

Defined terms

  • getClaims
  • Supabase SSR
  • JWT

Go deeper

  • JWT and Session Management in Supabase + Next.js, Explained
  • JWT Decoder

Ship these defenses by default

SecureStartKit is a Next.js, Supabase, and Stripe starter with Row Level Security, Zod-validated Server Actions, verified Stripe webhooks, and backend-only data access already wired in. Start from a secure baseline instead of hardening by hand.

Get SecureStartKit→Browse all patterns
← Back to all security patterns