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.
// 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.
// 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.
// 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.
// 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.
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().
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.
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.
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