TanStack Start 服务端认证原语实战:会话 Cookie、OAuth、CSRF 与限流的完整实现指南
【免费下载链接】router🤖 A client-first, server-capable, fully type-safe router and full-stack framework for the web (React and more).项目地址: https://gitcode.com/GitHub_Trending/ro/router
本指南围绕 TanStack Start(@tanstack/react-start)的服务端认证原语展开,系统讲解会话存储与 Cookie 签发、基于createServerFn与中间件的会话校验、OAuth 授权码 + PKCE 流程、密码重置防枚举、非 GET RPC 的 CSRF 防护、认证端点限流以及权限变更时的会话轮换。读完本文,你将掌握在 TanStack Start 中从零构建一套生产级服务端认证体系的能力,并理解路由守卫与数据边界之间必须严格区分的安全模型。
本文内容以仓库中的技能文档 auth-server-primitives/SKILL.md 为主体骨架,结合 start-client-core 与 start-server-core 的源码实现和 basic-auth 端到端示例 进行纵深展开。
认证的"服务端一半"与"路由一半"
TanStack Start 的认证体系由两半组成,二者职责严格分离:
- 服务端一半(本文主题):会话存储、Cookie 签发与读取、OAuth 流程、密码重置加固、CSRF、限流。全部运行在服务器上,属于数据安全边界。
- 路由一半:
_authenticated布局路由、beforeLoad重定向、RBAC 检查等页面级 UX 控制,详见 router-core/auth-and-guards/SKILL.md。
CRITICAL:优先保护数据/API 边界。凡是读写私有数据的服务端函数(
createServerFn)、服务端路由和其他 API 端点,必须在handler 内部或中间件中强制鉴权。路由守卫是路由层的 UX,不是数据安全边界。
CRITICAL:校验"形状"不等于授权。
z.string().uuid().parse(...)只能证明一个 UUID 格式合法,它仍然是某个租户的 ID——在使用该 ID 之前,必须回到会话主体(session principal)重新校验成员关系。
CRITICAL:会话/Cookie 的读取必须放在
.handler()或中间件.server()中,而不是模块顶层。模块级读取发生在任何请求存在之前(在 Cloudflare Workers 等边缘运行时上还会得到undefined)。
在生产环境上线前,逐条核对以下检查清单:
- 所有读取/写入私有用户、租户或账户数据的服务端函数、服务端路由或 API 端点都强制鉴权;
beforeLoad只用于页面 UX,不作为数据边界。 - 每个接收输入的服务端函数都使用
.validator()。 - 会话存放在
HttpOnly、Secure、SameSiteCookie 中;绝不将会话令牌放进localStorage或sessionStorage。 - 密码使用 bcrypt、scrypt 或 Argon2 哈希;对不存在的用户,用假哈希(dummy hash)校验并返回完全相同的登录/重置消息。
- 登录、注册、密码重置端点都做限流。
- 非 GET 的服务端函数与服务端路由启用 CSRF 或同源保护。
- 记录认证事件并监控失败。
- 直接测试对受保护服务端函数的未认证直连调用——它应在返回任何数据之前被拒绝。
- 跟随匿名重定向并检查其 HTML 与序列化状态;登录页和未授权页不得泄露受保护的用户、租户或记录信息。
会话 Cookie:把每一个标志位都设置到位
推荐的会话存储方式是一个仅 HTTP 的 Cookie,其中存放不透明会话 ID(服务端查表)或签名/加密令牌。Cookie 标志位直接决定安全性,必须全部设置:
// src/server/session.ts import { getRequestHeader, setResponseHeader, } from '@tanstack/react-start/server' const SESSION_COOKIE = '__Host-session' // __Host- 前缀将 Cookie 绑定到精确源 + 路径 '/' const ONE_DAY = 60 * 60 * 24 export function setSessionCookie(token: string) { setResponseHeader( 'Set-Cookie', [ `${SESSION_COOKIE}=${token}`, `HttpOnly`, // JS 无法读取——抵御 XSS 窃取 `Secure`, // 仅 HTTPS(__Host- 前缀的强制要求) `SameSite=Lax`, // 顶层导航会携带,挡住大部分 CSRF `Path=/`, // __Host- 前缀的强制要求 `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*/)) { // 只在第一个 '=' 处切分——签名/base64 值中常含 '='。 const eq = part.indexOf('=') if (eq === -1) continue if (part.slice(0, eq) === SESSION_COOKIE) return part.slice(eq + 1) } return null }各标志位的作用原理:
HttpOnly— JavaScript 无法读取该 Cookie,即使存在 XSS 漏洞也无法窃取会话。Secure— 仅允许 HTTPS 传输;使用__Host-前缀时强制要求。SameSite=Lax— 阻止绝大多数跨站 POST/PUT/DELETE 型 CSRF;对安全要求最高的流程(可接受丢失跨站 GET 导航)可使用Strict。__Host-前缀— 将 Cookie 绑定到精确源(禁止Domain属性、Path必须是/、必须设置Secure)。防止子域接管者伪造会话 Cookie。Path=/—__Host-前缀的强制要求。Max-Age— 有限生命周期,防止被盗 Cookie 永久有效;配合服务端会话轮换使用。
getRequestHeader/setResponseHeader等工具在 start-server-core/src/request-response.ts 中实现,底层基于 h3-v2 的H3Event与AsyncLocalStorage(全局符号tanstack-start:event-storage确保跨 bundle 共享同一存储实例),这正是"请求/响应上下文只在请求生命周期内可读"的实现根基,也解释了为何模块级读取会失效。
如果你更倾向于框架内置的密封会话(sealed session),@tanstack/react-start/server还提供useSession(),其配置项定义于 start-server-core/src/session.ts:password(加密会话令牌的私钥)、maxAge(过期秒数)、name(默认'start')、cookie(默认secure, httpOnly, /)、sessionHeader与seal选项。仓库的 basic-auth 示例 正是用useSession+ 服务端函数实现的完整登录闭环。
用中间件集中化会话加载
把会话加载收敛到中间件里,让每个受保护的 handler 都拿到带类型的会话:
// 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 } }) }, )把它挂到每一个需要登录用户的createServerFn上:
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 } }) })路由守卫覆盖不到这里。一个带
beforeLoad重定向的createFileRoute('/_authenticated/orders')保护不了getMyOrders——该 RPC 无论用户是否访问过这个路由都可以被直接调用。每个需要鉴权的服务端函数都必须挂authMiddleware(或在.handler()内自行复查)。
中间件的完整组合规则(方法顺序middleware()→validator()→client()→server()、上下文透传、全局中间件createStart)详见 start-core/middleware/SKILL.md。
登录时签发会话:恒定时间校验 + 权限变更轮换
// 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) // 即使用户不存在,也始终执行 verifyPasswordHash—— // 让"用户不存在"分支与"密码错误"分支耗时一致。 // DUMMY_PASSWORD_HASH 是在启动时用与真实哈希相同的算法/成本 // 对某个一次性口令计算得到的哈希。 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') // 权限变化时轮换:销毁已有会话,然后签发全新会话。 await db.sessions.revokeAllForUser(user.id) const token = await db.sessions.create({ userId: user.id }) setSessionCookie(token) return { ok: true } })在仓库的 basic-auth 示例 中可以看到同样的模式:loginFn是一个method: 'POST'的createServerFn,先查库、校验密码(示例用 PBKDF2 实现hashPassword,见 utils/prisma.ts;生产环境应改用 bcrypt/scrypt/Argon2),再通过useAppSession()更新会话数据。示例的根路由__root.tsx用服务端函数fetchUser在服务器上读取安全 Cookie 来判断登录态,这正是"读 Cookie 必须发生在请求回调内部"的落地范本。
登出:撤销会话并清除 Cookie
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 验证器
OAuth 授权码流程中,需要生成一次性state(CSRF 防御)和 PKCEverifier(防授权码被截获)。两者都存入一个短生命周期、签名、一次性的 Cookie,与本次登录尝试严格绑定:
// 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' // 快速过期;一次性 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`, }) }, )在回调 handler 中,必须验证 Cookie 中的 state 与返回的 state 一致,并用 verifier 兑换授权码。如果 state 缺失或不匹配,直接中止——该请求并非来自你的startOAuth。
密码重置:击败用户枚举
用户请求重置密码时,响应形态与耗时都不能暴露邮箱是否注册:
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) } // 无论用户是否存在,始终返回 200 与相同的响应体。 // 只告知用户"请检查收件箱",不给出任何确认或否认。 return { ok: true } })禁止的做法:
- 存在返回 200、不存在返回 404。
- 使用不同文案("我们已发送链接" vs "未找到账号")。
- 用户不存在时跳过工作(产生可从网络测量的时序泄漏)。
非 GET RPC 的 CSRF 防护
会话 Cookie 上的SameSite=Lax能挡住大多数跨站 POST/PUT/DELETE CSRF。但有两种情况需要额外防御:
- 会变更状态的顶层 GET 导航——永远不要这样做,变更操作一律使用 POST/PUT/DELETE。
- 来自兄弟子域页面的 POST——
SameSite=Lax挡不住这种情况,需要在中间件中校验Origin头与应用源一致。
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') // 比较完整的源(scheme + host + port)——仅比较 host 会让 // http://example.com 通过本应为 https://example.com 设计的检查。 if (!origin || new URL(origin).origin !== process.env.APP_ORIGIN) { throw new Error('Origin check failed') } } return next() })把它挂到src/start.ts的全局请求中间件上,即可覆盖包括服务端路由和 SSR 在内的所有非 GET 请求。
值得指出的是,仓库还内置了一个开箱即用的createCsrfMiddleware,实现在 start-client-core/src/createCsrfMiddleware.ts:
- 优先校验
Sec-Fetch-Site(默认'same-origin'),缺失时回退校验Origin(默认与请求 URL 同源),再回退校验Referer(默认true); - 支持
filter过滤、origin/secFetchSite自定义匹配器(值、数组或函数)、allowRequestsWithoutOriginCheck与failureResponse(默认403 Forbidden); - 校验失败路径与上述手写中间件语义一致,但覆盖了更完整的浏览器头协商场景。
认证端点的手写中间件与框架内置 CSRF 中间件可以组合使用,前者负责业务鉴权,后者负责跨站请求防护。
认证端点限流
没有限流的登录端点就是撞库(credential-stuffing)的目标。按 IP(理想情况下再按账号)使用滑动窗口限流:
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() }) } // 挂到登录服务端函数上: export const login = createServerFn({ method: 'POST' }).middleware([ rateLimitMiddleware({ key: 'login', max: 5, windowMs: 60_000 }), ]) // ...权限变化时的会话轮换
用户的权限一旦变化——登录、登出、角色变更、密码变更——销毁旧会话并签发新会话。这能中和会话固定(session-fixation)攻击:攻击者在受害者登录前把自己的会话 ID 植入其浏览器。
// 登录 handler 中(上文已展示):销毁登录前存在的任何会话,然后创建全新会话。 await db.sessions.revokeAllForUser(user.id) const token = await db.sessions.create({ userId: user.id }) setSessionCookie(token)// 密码变更 / 授权提权时: await db.sessions.revokeAllForUser(user.id) // 销毁已有会话 const token = await db.sessions.create({ userId: user.id }) // 签发全新会话 setSessionCookie(token)常见错误清单
CRITICAL:把路由守卫当作服务端函数的鉴权
// 错误 —— 无论路由如何,该 RPC 都可以通过 POST 直接调用 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() // ← 任何人都能直连 RPC 拿到全部订单 }) // 正确 —— 在 handler 本身上强制鉴权 const getMyOrders = createServerFn({ method: 'GET' }) .middleware([authMiddleware]) .handler(async ({ context }) => { return db.orders.findMany({ where: { userId: context.session.userId } }) })这与 server-functions/SKILL.md 中"服务端函数是可独立到达的 API 端点"的警告完全一致:路由beforeLoad是 UX,端点鉴权才是数据安全边界。
CRITICAL:把形状校验当成授权
一个解析成功的 UUID 是某个工作区,而不是被授权访问的工作区:
// 错误 —— UUID 格式合法,但用户可能不是成员 const getWorkspaceData = createServerFn({ method: 'GET' }) .middleware([authMiddleware]) .validator(z.object({ workspaceId: z.string().uuid() })) .handler(async ({ context, data }) => { return db.workspaces.findById(data.workspaceId) // 缺少成员关系检查! }) // 正确 —— 校验会话主体对该工作区是否有访问权 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) })这条规则同样适用于客户端中间件sendContext传来的任何标识符——客户端能发送的,客户端就能伪造,会话必须来自 Cookie + DB 查表这一服务端可信来源。
HIGH:根据邮箱是否存在返回不同响应
上文已覆盖——requestPasswordReset无论邮箱是否匹配用户,都必须返回相同的响应体。
HIGH:在模块顶层读取 Cookie / 环境变量
// 错误 —— 模块加载时执行,此时还没有任何请求 const SESSION_SECRET = process.env.SESSION_SECRET export function signSession(payload) { return sign(payload, SESSION_SECRET) } // 正确 —— 在每个请求的回调内部读取 export function signSession(payload) { return sign(payload, process.env.SESSION_SECRET) }在 Cloudflare Workers 等边缘运行时上,即使是在服务端,模块级读取也会得到undefined——因为 env 是按请求注入的。参见 start-core/execution-model/SKILL.md。
MEDIUM:会话长期有效且从不轮换
从不轮换的会话令牌在功能上等同于长期凭据。必须在登录、登出、密码变更、角色/权限变更时轮换。
路由侧的配套实现
本技能文档专注于服务端原语,路由侧的配套模式(_authenticated布局、beforeLoad+redirect、RBAC 检查、isRedirect处理)请参阅 router-core/auth-and-guards/SKILL.md。二者配合的典型形态是:路由守卫负责页面导航体验(未登录重定向到/login并携带redirect回跳参数),服务端函数与中间件负责真正的数据安全。
交叉参考
- router-core/auth-and-guards/SKILL.md — 路由侧:
_authenticated布局、beforeLoad、redirect、RBAC 检查。 - start-core/server-functions/SKILL.md — 如何暴露 RPC(以及为什么路由守卫覆盖不到它们)。
- start-core/middleware/SKILL.md — 组合
authMiddleware及其他中间件。 - start-core/execution-model/SKILL.md — 为什么模块级 env/secret 读取是错的。
- basic-auth 示例 — 一个完整的、可运行的密码认证 + 会话 + 受保护路由闭环实现。
【免费下载链接】router🤖 A client-first, server-capable, fully type-safe router and full-stack framework for the web (React and more).项目地址: https://gitcode.com/GitHub_Trending/ro/router
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考