Skip to main content

auth-server-primitives

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.

Informations de source

Dépôt
TanStack/router
Dernière activité de la source
12 octobre 2026 à 22:01
Langue détectée de SKILL.md
anglais
Étoiles
15 202
Forks
1 900

Options d'installation

Le prompt qui vérifie d'abord la source est sélectionné par défaut. Vous pouvez passer à une commande directe ou télécharger une copie locale.

Vérifiez les fichiers source

Lisez SKILL.md et les fichiers associés affichés par SkillsMP avant de décider de l'installer.

Affichage de SKILL.md

SKILL.md
Instructions source · Aperçu en lecture seule
name
auth-server-primitives
description
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.
metadata
{"type":"sub-skill","library":"tanstack-start","library_version":"1.170.14"}
requires
["start-core","start-core/server-functions","start-core/middleware"]
sources
["TanStack/router:docs/start/framework/react/guide/authentication-overview.md","TanStack/router:docs/start/framework/react/guide/authentication-server-primitives.md"]
# Auth Server Primitives This skill covers the **server half** of authentication: session storage, cookie issuance, OAuth flow, password-reset hardening, CSRF, rate limiting. For the **routing half** (`_authenticated` layout, `beforeLoad` redirects, RBAC checks), see [router-core/auth-and-guards](../../../../router-core/skills/router-core/auth-and-guards/SKILL.md). > **CRITICAL**: 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 (`z.string().uuid().parse(...)`) is not authorization. A parsed UUID is still _some_ tenant — re-check membership against the session principal before using it. > **CRITICAL**: Read session/cookies inside `.handler()` or middleware `.server()`, not at module scope. Module-level reads run before requests exist (and are also undefined on Cloudflare Workers). ## Production Checklist - Enforce auth in every server function, server route, or API endpoint that reads or writes private user, tenant, or account data. Use route `beforeLoad` for page UX, not as the data boundary. - Use `.validator()` on every server function that accepts input. - Store sessions in `HttpOnly`, `Secure`, `SameSite` cookies. Do not store session tokens in `localStorage` or `sessionStorage`. - Hash passwords with bcrypt, scrypt, or Argon2. For missing users, verify against a dummy hash and return the same login/reset message. - Rate limit login, registration, and password-reset endpoints. - Use CSRF or same-origin protections for non-GET server functions and server routes. - Log authentication events and monitor failures. - Test direct unauthenticated calls to protected server functions; they should reject before returning data. - Follow the anonymous redirect and inspect its HTML and serialized state. Login and unauthorized pages must not name the protected user, tenant, or record. ## Session Cookies The recommended session storage is an HTTP-only cookie holding either an opaque session ID (with server-side lookup) or a signed/encrypted token. The cookie flags matter — set them all. ```tsx // src/server/session.ts import { deleteCookie, getCookie, setCookie, } from '@tanstack/react-start/server' const SESSION_COOKIE = '__Host-session' // __Host- prefix binds to the exact origin + path '/' const ONE_DAY = 60 * 60 * 24 const sessionCookieOptions = { httpOnly: true, // not readable from JS — defeats XSS exfiltration secure: true, // HTTPS only (required for __Host- prefix) sameSite: 'lax', // sent on top-level navigations, blocks most CSRF path: '/', // required for __Host- prefix } as const export function setSessionCookie(token: string) { setCookie(SESSION_COOKIE, token, { ...sessionCookieOptions, maxAge: ONE_DAY, }) } export function clearSessionCookie() { deleteCookie(SESSION_COOKIE, sessionCookieOptions) } export function readSessionToken(): string | null { return getCookie(SESSION_COOKIE) ?? null } ``` Write cookies with `setCookie` (or `appendResponseHeader('set-cookie', serialized)` for a cookie that a library already serialized). Do not use `setResponseHeader('Set-Cookie', ...)` for this: it replaces every cookie on the outgoing response, including cookies set by other middleware or returned by a handler. Flag rationale: - `HttpOnly` — JavaScript can't read the cookie, so an XSS bug can't steal the session. - `Secure` — HTTPS only. Required when using `__Host-` prefix. - `SameSite=Lax` — blocks CSRF on most cross-origin POST/PUT/DELETE. Use `Strict` for highest-security flows where loss of cross-site GET navigation is acceptable. - `__Host-` prefix — binds the cookie to the exact origin (no Domain attribute, Path must be `/`, Secure must be set). Prevents subdomain takeover from forging a session cookie. - `Path=/` — required by `__Host-`. - `Max-Age` — finite lifetime so a stolen cookie isn't useful forever. Pair with server-side session rotation. ## Session Lookup as Middleware Use middleware to centralize session loading so every protected handler sees a typed session: ```tsx // 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 it to every server function that needs a logged-in user: ```tsx 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.** A `createFileRoute('/_authenticated/orders')` with a `beforeLoad` redirect does NOT protect `getMyOrders` — the RPC is reachable directly whether or not the user ever hits the route. Apply `authMiddleware` (or re-check inside `.handler()`) on every server function that needs auth. ## Issuing a Session on Login ```tsx // 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 } }) ``` ## Logout ```tsx 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 } }) ``` ## OAuth: state + PKCE For OAuth authorization-code flow, generate a one-time `state` (CSRF defense) and a PKCE verifier (defense against authorization-code interception). Store both in a short-lived signed cookie keyed to this exact login attempt. ```tsx // src/server/oauth.functions.ts import { createServerFn } from '@tanstack/react-start' import { redirect } from '@tanstack/react-router' import { setCookie } 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(), ) setCookie(OAUTH_STATE_COOKIE, signed({ state, verifier }), { httpOnly: true, secure: true, sameSite: 'lax', path: '/', maxAge: 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, **verify the cookie state matches the returned state** and exchange the code with the verifier. If state is missing or doesn't match, abort — the request did not originate from your `startOAuth`. ## Password Reset: defeat user enumeration When a user requests a reset, do not let the response shape or timing reveal whether the email is registered. ```tsx import { 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 } }) ``` Do NOT: - Return 200 if exists, 404 if not. - Use a different message ("we sent you a link" vs "no account found"). - Skip the work when the user doesn't exist (timing leak — measurable from the wire). ## CSRF for non-GET RPCs `SameSite=Lax` on the session cookie blocks most cross-site CSRF for POST/PUT/DELETE. Two cases need extra defense: 1. **Top-level GET navigation that mutates** — never do this. Always use POST/PUT/DELETE for mutations. 2. **POST from a page on a sibling subdomain** — `SameSite=Lax` does NOT block this; verify the `Origin` header matches your app's origin in middleware. ```tsx 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 to global request middleware in `src/start.ts` so it covers every non-GET request, including server routes and SSR. ## Rate Limiting Auth Endpoints A login endpoint without rate limiting is a credential-stuffing target. Limit per-IP (and ideally per-account) with a sliding window. ```tsx 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 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 }), ]) // ... ``` ## Session Rotation on Privilege Change Whenever the user's privileges change — login, logout, role change, password change — **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 login. ```tsx // 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) ``` ```tsx // 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) ``` ## Common Mistakes ### CRITICAL: Trusting the route guard for server-function auth ```tsx // 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 } }) }) ``` ### CRITICAL: Treating shape validation as authorization A parsed UUID is _some_ workspace, not an _authorized_ workspace. ```tsx // WRONG — UUID is well-formed but the user may not be a member const getWorkspaceData = createServerFn({ method: 'GET' }) .middleware([authMiddleware])
Voir sur GitHub
Ce SKILL.md est tres volumineux, SkillsMP affiche donc ici seulement la premiere section. Voir sur GitHub