This guide covers the server-side primitives for building authentication in TanStack Start: session cookies, session lookup, OAuth, password-reset hardening, CSRF, and rate limiting. It pairs with the routing-side guide (_authenticated layout, beforeLoad, redirects, RBAC).
If you can use a managed solution like Clerk or WorkOS, prefer that — they handle most of what this guide describes. Read on if you're rolling your own.
Authentication has a routing half and a server half. They both need to be implemented; either one alone is incomplete.
A route guard does NOT protect a createServerFn on that route. Server functions are RPC endpoints — they're reachable via direct POST regardless of which route renders them. Auth must be enforced on the handler (or via middleware), not on the calling route.
The default session storage is an HTTP-only cookie. The cookie can hold:
Whichever you choose, the cookie flags matter:
// src/server/session.ts
import {
getRequestHeader,
setResponseHeader,
} from '@tanstack/react-start/server'
const SESSION_COOKIE = '__Host-session'
const ONE_DAY = 60 * 60 * 24
export function setSessionCookie(token: string) {
setResponseHeader(
'Set-Cookie',
[
`${SESSION_COOKIE}=${token}`,
`HttpOnly`,
`Secure`,
`SameSite=Lax`,
`Path=/`,
`Max-Age=${ONE_DAY}`,
].join('; '),
)
}
export function clearSessionCookie() {
setResponseHeader(
'Set-Cookie',
`${SESSION_COOKIE}=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0`,
)
}
export function readSessionToken(): string | null {
const header = getRequestHeader('cookie')
if (!header) return null
for (const part of header.split(/;\s*/)) {
// Split only on the FIRST '=' — signed/base64 values often contain '='.
const eq = part.indexOf('=')
if (eq === -1) continue
if (part.slice(0, eq) === SESSION_COOKIE) return part.slice(eq + 1)
}
return null
}// src/server/session.ts
import {
getRequestHeader,
setResponseHeader,
} from '@tanstack/react-start/server'
const SESSION_COOKIE = '__Host-session'
const ONE_DAY = 60 * 60 * 24
export function setSessionCookie(token: string) {
setResponseHeader(
'Set-Cookie',
[
`${SESSION_COOKIE}=${token}`,
`HttpOnly`,
`Secure`,
`SameSite=Lax`,
`Path=/`,
`Max-Age=${ONE_DAY}`,
].join('; '),
)
}
export function clearSessionCookie() {
setResponseHeader(
'Set-Cookie',
`${SESSION_COOKIE}=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0`,
)
}
export function readSessionToken(): string | null {
const header = getRequestHeader('cookie')
if (!header) return null
for (const part of header.split(/;\s*/)) {
// Split only on the FIRST '=' — signed/base64 values often contain '='.
const eq = part.indexOf('=')
if (eq === -1) continue
if (part.slice(0, eq) === SESSION_COOKIE) return part.slice(eq + 1)
}
return null
}| Flag | Why |
|---|---|
| HttpOnly | JavaScript can't read the cookie. An XSS bug can't exfiltrate the session. |
| Secure | HTTPS only. Required when using the __Host- prefix. |
| SameSite=Lax | Sent on top-level navigations; blocks most cross-site CSRF on POST. Use Strict for higher-risk apps where loss of cross-site GET navigation is acceptable. |
| __Host- prefix | Binds the cookie to the exact origin. No Domain attribute, Path=/, Secure required. Defeats subdomain-takeover session fixation. |
| Path=/ | Required by __Host-. |
| Max-Age | Bounded lifetime. Pair with server-side rotation. |
Centralize session loading in middleware so every protected handler sees a typed session:
// src/server/auth-middleware.ts
import { createMiddleware } from '@tanstack/react-start'
import { readSessionToken } from './session'
export const authMiddleware = createMiddleware({ type: 'function' }).server(
async ({ next }) => {
const token = readSessionToken()
const session = token ? await db.sessions.findValid(token) : null
if (!session) throw new Error('Unauthorized')
return next({ context: { session } })
},
)// src/server/auth-middleware.ts
import { createMiddleware } from '@tanstack/react-start'
import { readSessionToken } from './session'
export const authMiddleware = createMiddleware({ type: 'function' }).server(
async ({ next }) => {
const token = readSessionToken()
const session = token ? await db.sessions.findValid(token) : null
if (!session) throw new Error('Unauthorized')
return next({ context: { session } })
},
)Attach to every protected server function:
import { createServerFn } from '@tanstack/react-start'
import { authMiddleware } from '~/server/auth-middleware'
export const getMyOrders = createServerFn({ method: 'GET' })
.middleware([authMiddleware])
.handler(async ({ context }) => {
return db.orders.findMany({ where: { userId: context.session.userId } })
})import { createServerFn } from '@tanstack/react-start'
import { authMiddleware } from '~/server/auth-middleware'
export const getMyOrders = createServerFn({ method: 'GET' })
.middleware([authMiddleware])
.handler(async ({ context }) => {
return db.orders.findMany({ where: { userId: context.session.userId } })
})// src/server/login.functions.ts
import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
import { setSessionCookie } from './session'
export const login = createServerFn({ method: 'POST' })
.inputValidator(z.object({ email: z.string().email(), password: z.string() }))
.handler(async ({ data }) => {
const user = await db.users.findByEmail(data.email)
// Always run verifyPasswordHash — even when the user doesn't exist —
// so the user-not-found branch takes the same time as wrong-password.
// DUMMY_PASSWORD_HASH is a hash of any throwaway password computed once
// at startup with the same algorithm/cost as real password hashes.
const hashToCheck = user?.passwordHash ?? DUMMY_PASSWORD_HASH
const passwordMatches = await verifyPasswordHash(hashToCheck, data.password)
const ok = user != null && passwordMatches
if (!ok) throw new Error('Invalid email or password')
// Rotate: destroy any existing session, then issue fresh.
await db.sessions.revokeAllForUser(user.id)
const token = await db.sessions.create({ userId: user.id })
setSessionCookie(token)
return { ok: true }
})// src/server/login.functions.ts
import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
import { setSessionCookie } from './session'
export const login = createServerFn({ method: 'POST' })
.inputValidator(z.object({ email: z.string().email(), password: z.string() }))
.handler(async ({ data }) => {
const user = await db.users.findByEmail(data.email)
// Always run verifyPasswordHash — even when the user doesn't exist —
// so the user-not-found branch takes the same time as wrong-password.
// DUMMY_PASSWORD_HASH is a hash of any throwaway password computed once
// at startup with the same algorithm/cost as real password hashes.
const hashToCheck = user?.passwordHash ?? DUMMY_PASSWORD_HASH
const passwordMatches = await verifyPasswordHash(hashToCheck, data.password)
const ok = user != null && passwordMatches
if (!ok) throw new Error('Invalid email or password')
// Rotate: destroy any existing session, then issue fresh.
await db.sessions.revokeAllForUser(user.id)
const token = await db.sessions.create({ userId: user.id })
setSessionCookie(token)
return { ok: true }
})The Invalid email or password message is identical for "user not found" and "wrong password". The dummy-hash technique above also makes the timing identical: without it, the no-user branch returns instantly while the wrong-password branch spends ~100ms on the hash compare, leaking account existence over the wire.
import { createServerFn } from '@tanstack/react-start'
import { authMiddleware } from '~/server/auth-middleware'
import { clearSessionCookie } from '~/server/session'
export const logout = createServerFn({ method: 'POST' })
.middleware([authMiddleware])
.handler(async ({ context }) => {
await db.sessions.revoke(context.session.id)
clearSessionCookie()
return { ok: true }
})import { createServerFn } from '@tanstack/react-start'
import { authMiddleware } from '~/server/auth-middleware'
import { clearSessionCookie } from '~/server/session'
export const logout = createServerFn({ method: 'POST' })
.middleware([authMiddleware])
.handler(async ({ context }) => {
await db.sessions.revoke(context.session.id)
clearSessionCookie()
return { ok: true }
})For OAuth authorization-code flow:
// src/server/oauth.functions.ts
import { createServerFn } from '@tanstack/react-start'
import { redirect } from '@tanstack/react-router'
import { setResponseHeader } from '@tanstack/react-start/server'
import crypto from 'node:crypto'
const OAUTH_STATE_COOKIE = '__Host-oauth'
function base64url(buf: Buffer) {
return buf
.toString('base64')
.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_')
}
export const startOAuth = createServerFn({ method: 'GET' }).handler(
async () => {
const state = base64url(crypto.randomBytes(32))
const verifier = base64url(crypto.randomBytes(32))
const challenge = base64url(
crypto.createHash('sha256').update(verifier).digest(),
)
setResponseHeader(
'Set-Cookie',
`${OAUTH_STATE_COOKIE}=${signed({ state, verifier })}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=600`,
)
throw redirect({
href:
`https://provider.example/authorize` +
`?response_type=code` +
`&client_id=${process.env.OAUTH_CLIENT_ID}` +
`&redirect_uri=${encodeURIComponent(process.env.OAUTH_REDIRECT_URI!)}` +
`&state=${state}` +
`&code_challenge=${challenge}` +
`&code_challenge_method=S256`,
})
},
)// src/server/oauth.functions.ts
import { createServerFn } from '@tanstack/react-start'
import { redirect } from '@tanstack/react-router'
import { setResponseHeader } from '@tanstack/react-start/server'
import crypto from 'node:crypto'
const OAUTH_STATE_COOKIE = '__Host-oauth'
function base64url(buf: Buffer) {
return buf
.toString('base64')
.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_')
}
export const startOAuth = createServerFn({ method: 'GET' }).handler(
async () => {
const state = base64url(crypto.randomBytes(32))
const verifier = base64url(crypto.randomBytes(32))
const challenge = base64url(
crypto.createHash('sha256').update(verifier).digest(),
)
setResponseHeader(
'Set-Cookie',
`${OAUTH_STATE_COOKIE}=${signed({ state, verifier })}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=600`,
)
throw redirect({
href:
`https://provider.example/authorize` +
`?response_type=code` +
`&client_id=${process.env.OAUTH_CLIENT_ID}` +
`&redirect_uri=${encodeURIComponent(process.env.OAUTH_REDIRECT_URI!)}` +
`&state=${state}` +
`&code_challenge=${challenge}` +
`&code_challenge_method=S256`,
})
},
)In the callback handler:
If any of those checks fail, the request did not originate from your startOAuth and must be rejected.
The reset endpoint must NOT tell the caller whether a given email is registered. Returning 200 vs 404 — or even different copy — leaks user existence to anyone who can hit the endpoint.
import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
export const requestPasswordReset = createServerFn({ method: 'POST' })
.inputValidator(z.object({ email: z.string().email() }))
.handler(async ({ data }) => {
const user = await db.users.findByEmail(data.email)
if (user) {
const token = await db.passwordResets.issue(user.id)
await sendResetEmail(user.email, token)
}
// Same response, same body, regardless of existence.
return { ok: true }
})import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
export const requestPasswordReset = createServerFn({ method: 'POST' })
.inputValidator(z.object({ email: z.string().email() }))
.handler(async ({ data }) => {
const user = await db.users.findByEmail(data.email)
if (user) {
const token = await db.passwordResets.issue(user.id)
await sendResetEmail(user.email, token)
}
// Same response, same body, regardless of existence.
return { ok: true }
})Do NOT:
SameSite=Lax on the session cookie blocks most cross-site CSRF for POST/PUT/DELETE. Two cases need explicit defense:
import { createMiddleware } from '@tanstack/react-start'
import { getRequest } from '@tanstack/react-start/server'
export const csrfMiddleware = createMiddleware().server(async ({ next }) => {
const request = getRequest()
if (request.method !== 'GET' && request.method !== 'HEAD') {
const origin = request.headers.get('origin')
// Compare the FULL origin (scheme + host + port) — host alone lets
// http://example.com pass a check meant for https://example.com.
if (!origin || new URL(origin).origin !== process.env.APP_ORIGIN) {
throw new Error('Origin check failed')
}
}
return next()
})import { createMiddleware } from '@tanstack/react-start'
import { getRequest } from '@tanstack/react-start/server'
export const csrfMiddleware = createMiddleware().server(async ({ next }) => {
const request = getRequest()
if (request.method !== 'GET' && request.method !== 'HEAD') {
const origin = request.headers.get('origin')
// Compare the FULL origin (scheme + host + port) — host alone lets
// http://example.com pass a check meant for https://example.com.
if (!origin || new URL(origin).origin !== process.env.APP_ORIGIN) {
throw new Error('Origin check failed')
}
}
return next()
})Attach this in src/start.ts global requestMiddleware so it runs on every non-GET request, including server routes and SSR.
A login endpoint without rate limiting is a credential-stuffing target. Limit per IP (and per-account if you can identify the user) with a sliding window or token bucket.
import { createMiddleware } from '@tanstack/react-start'
import { getRequest } from '@tanstack/react-start/server'
function rateLimitMiddleware(opts: {
key: string
max: number
windowMs: number
}) {
return createMiddleware().server(async ({ next }) => {
const request = getRequest()
const ip =
request.headers.get('cf-connecting-ip') ??
request.headers.get('x-forwarded-for')?.split(',')[0] ??
'unknown'
const allowed = await rateLimiter.consume(
`rl:${opts.key}:${ip}`,
opts.max,
opts.windowMs,
)
if (!allowed) throw new Error('Too many requests')
return next()
})
}
export const login = createServerFn({ method: 'POST' }).middleware([
rateLimitMiddleware({ key: 'login', max: 5, windowMs: 60_000 }),
])
// ...import { createMiddleware } from '@tanstack/react-start'
import { getRequest } from '@tanstack/react-start/server'
function rateLimitMiddleware(opts: {
key: string
max: number
windowMs: number
}) {
return createMiddleware().server(async ({ next }) => {
const request = getRequest()
const ip =
request.headers.get('cf-connecting-ip') ??
request.headers.get('x-forwarded-for')?.split(',')[0] ??
'unknown'
const allowed = await rateLimiter.consume(
`rl:${opts.key}:${ip}`,
opts.max,
opts.windowMs,
)
if (!allowed) throw new Error('Too many requests')
return next()
})
}
export const login = createServerFn({ method: 'POST' }).middleware([
rateLimitMiddleware({ key: 'login', max: 5, windowMs: 60_000 }),
])
// ...Whenever the user's privileges change — login, logout, password change, role grant — destroy the old session and issue a new one. This neutralizes session-fixation attacks where an attacker plants their own session ID in the victim's browser before the privilege change.
// On login: revoke any pre-login session, create fresh.
await db.sessions.revokeAllForUser(user.id)
const token = await db.sessions.create({ userId: user.id })
setSessionCookie(token)
// On password change / role grant:
await db.sessions.revokeAllForUser(user.id)
const token = await db.sessions.create({ userId: user.id })
setSessionCookie(token)// On login: revoke any pre-login session, create fresh.
await db.sessions.revokeAllForUser(user.id)
const token = await db.sessions.create({ userId: user.id })
setSessionCookie(token)
// On password change / role grant:
await db.sessions.revokeAllForUser(user.id)
const token = await db.sessions.create({ userId: user.id })
setSessionCookie(token)Module-scope reads are wrong on two axes:
// ❌ Wrong
const SESSION_SECRET = process.env.SESSION_SECRET
export function signSession(payload) {
return sign(payload, SESSION_SECRET)
}
// ✅ Right
export function signSession(payload) {
return sign(payload, process.env.SESSION_SECRET)
}// ❌ Wrong
const SESSION_SECRET = process.env.SESSION_SECRET
export function signSession(payload) {
return sign(payload, SESSION_SECRET)
}
// ✅ Right
export function signSession(payload) {
return sign(payload, process.env.SESSION_SECRET)
}See Execution Model: Module-Level process.env Reads for the full rule.