Loading...
Loading...
Server-side authentication primitives for TanStack Start: session cookies (HttpOnly, Secure, SameSite, __Host- prefix), session read/issue/destroy via createServerFn and middleware, OAuth authorization-code flow with state and PKCE, password-reset enumeration defense, CSRF for non-GET RPCs, rate limiting auth endpoints, session rotation on privilege change. Pairs with router-core/auth-and-guards for the routing side.
npx skill4agent add tanstack/router auth-server-primitives_authenticatedbeforeLoadCRITICAL: Protect the data/API boundary first. Server functions, server routes, and other API endpoints that touch private data must enforce auth inside the handler or middleware. Route guards are route UX, not the data security boundary. CRITICAL: Validating the shape of a client-supplied identifier () is not authorization. A parsed UUID is still some tenant — re-check membership against the session principal before using it. CRITICAL: Read session/cookies insidez.string().uuid().parse(...)or middleware.handler(), not at module scope. Module-level reads run before requests exist (and are also undefined on Cloudflare Workers)..server()
beforeLoad.validator()HttpOnlySecureSameSitelocalStoragesessionStorage// src/server/session.ts
import {
getRequestHeader,
setResponseHeader,
} from '@tanstack/react-start/server'
const SESSION_COOKIE = '__Host-session' // __Host- prefix binds to the exact origin + path '/'
const ONE_DAY = 60 * 60 * 24
export function setSessionCookie(token: string) {
setResponseHeader(
'Set-Cookie',
[
`${SESSION_COOKIE}=${token}`,
`HttpOnly`, // not readable from JS — defeats XSS exfiltration
`Secure`, // HTTPS only (required for __Host- prefix)
`SameSite=Lax`, // sent on top-level navigations, blocks most CSRF
`Path=/`, // required for __Host- prefix
`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
}HttpOnlySecure__Host-SameSite=LaxStrict__Host-/Path=/__Host-Max-Age// 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 } })
},
)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 } })
})Route guards do not cover this. Awith acreateFileRoute('/_authenticated/orders')redirect does NOT protectbeforeLoad— the RPC is reachable directly whether or not the user ever hits the route. ApplygetMyOrders(or re-check insideauthMiddleware) on every server function that needs auth..handler()
// 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' })
.validator(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 on privilege change: 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 }
})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 }
})state// src/server/oauth.functions.ts
import { createServerFn } from '@tanstack/react-start'
import { redirect } from '@tanstack/react-router'
import {
getRequestHeader,
setResponseHeader,
} from '@tanstack/react-start/server'
import crypto from 'node:crypto'
const OAUTH_STATE_COOKIE = '__Host-oauth' // expires fast; one-shot
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`,
})
},
)startOAuthimport { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
export const requestPasswordReset = createServerFn({ method: 'POST' })
.validator(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)
}
// Always 200, always the same body, regardless of whether the user exists.
// The user is told to check their inbox; no confirmation either way.
return { ok: true }
})SameSite=LaxSameSite=LaxOriginimport { 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()
})src/start.tsimport { 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 bucketKey = `rl:${opts.key}:${ip}`
const allowed = await rateLimiter.consume(
bucketKey,
opts.max,
opts.windowMs,
)
if (!allowed) throw new Error('Too many requests')
return next()
})
}
// On the login server function:
export const login = createServerFn({ method: 'POST' }).middleware([
rateLimitMiddleware({ key: 'login', max: 5, windowMs: 60_000 }),
])
// ...// In the login handler (already shown above): destroy any pre-login session, then create a fresh one.
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) // destroy existing
const token = await db.sessions.create({ userId: user.id }) // issue fresh
setSessionCookie(token)// WRONG — the RPC is callable directly via POST regardless of the route
export const Route = createFileRoute('/_authenticated/orders')({
beforeLoad: ({ context }) => {
if (!context.auth.isAuthenticated) throw redirect({ to: '/login' })
},
})
const getMyOrders = createServerFn({ method: 'GET' }).handler(async () => {
return db.orders.findMany() // ← anyone can hit the RPC and get all orders
})
// CORRECT — auth enforced on the handler itself
const getMyOrders = createServerFn({ method: 'GET' })
.middleware([authMiddleware])
.handler(async ({ context }) => {
return db.orders.findMany({ where: { userId: context.session.userId } })
})// WRONG — UUID is well-formed but the user may not be a member
const getWorkspaceData = createServerFn({ method: 'GET' })
.middleware([authMiddleware])
.validator(z.object({ workspaceId: z.string().uuid() }))
.handler(async ({ context, data }) => {
return db.workspaces.findById(data.workspaceId) // missing membership check!
})
// CORRECT — verify the session principal has access to that workspace
const getWorkspaceData = createServerFn({ method: 'GET' })
.middleware([authMiddleware])
.validator(z.object({ workspaceId: z.string().uuid() }))
.handler(async ({ context, data }) => {
const member = await db.memberships.find({
userId: context.session.userId,
workspaceId: data.workspaceId,
})
if (!member) throw new Error('Not a member of this workspace')
return db.workspaces.findById(data.workspaceId)
})requestPasswordReset// WRONG — module-load time, before any request exists
const SESSION_SECRET = process.env.SESSION_SECRET
export function signSession(payload) {
return sign(payload, SESSION_SECRET)
}
// CORRECT — read inside per-request callback
export function signSession(payload) {
return sign(payload, process.env.SESSION_SECRET)
}undefined_authenticatedbeforeLoadredirectauthMiddleware