认证服务器原语
本指南涵盖在 TanStack Start 中构建认证所需的服务端原语(server-side primitives):会话 cookie、会话查找、OAuth、密码重置加固、CSRF 和限流。它与路由侧的指南(_authenticated 布局、beforeLoad、重定向、RBAC)配套使用。
如果你可以使用 Clerk 或 WorkOS 这类托管方案,请优先使用它们——它们处理了本指南描述的大部分内容。如果你想自己动手,请继续阅读。
先保护数据
认证有一个数据/API 边界和一个路由/UI 层。数据边界才是安全边界:每个读写私有数据的服务器函数、服务器路由或 API 端点,都必须在返回数据或改变状态之前授权请求。
- 数据/API 边界(本指南):签发和验证会话 cookie、交换 OAuth code、哈希和验证密码、对凭据端点限流、阻止用户枚举、授权私有数据访问。
- 路由/UI 层(Router 的 auth-and-guards):把未认证用户从他们无法使用的页面重定向走、基于角色/权限控制 UI、展示登录表单,以及避免触发注定会失败的请求。
注意
路由守卫不是数据授权边界。 服务器函数和服务器路由是 API 端点,它们可以独立于调用它们的路由被访问。认证必须在接触私有数据的端点的 handler 或中间件中强制执行。beforeLoad 是用来做路由 UX 的。
会话 Cookie
默认的会话存储是 HTTP-only cookie。cookie 可以存放:
- 一个不透明会话 ID(opaque session ID),服务器在数据库中查找它(推荐——易于撤销)。
- 一个签名/加密的令牌,它自己携带会话负载(无状态,但撤销更难)。
无论你选择哪种,cookie 标志都很重要:
// 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
}| 标志 | 为什么 |
|---|---|
HttpOnly | JavaScript 无法读取该 cookie。XSS 漏洞无法窃取会话。 |
Secure | 仅 HTTPS。使用 __Host- 前缀时必需。 |
SameSite=Lax | 在顶层导航时发送;能阻止大多数跨站 POST CSRF。对更高风险的应用,如果接受丢失跨站 GET 导航,可以用 Strict。 |
__Host- 前缀 | 把 cookie 绑定到确切来源。不设置 Domain 属性、Path=/、必须 Secure。能挫败子域名接管导致的会话固定攻击。 |
Path=/ | __Host- 前缀所必需。 |
Max-Age | 有界生命周期。与服务端轮换配合使用。 |
把会话查找做成中间件
把会话加载集中到中间件里,让每个受保护的 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 } })
},
)把它挂到每个受保护的服务器函数上:
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' })
.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: 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 }
})Invalid email or password(邮箱或密码无效)这条消息在「用户不存在」和「密码错误」两种情况下完全一致。上面的 dummy-hash 技术还让执行时间也一致:如果不这样做,「用户不存在」分支会立刻返回,而「密码错误」分支要花约 100ms 做哈希比较,这会在网络上泄露账户是否存在。
译者注:dummy-hash 与用户枚举防护
认证里有个经典攻击叫「用户枚举(user enumeration)」:攻击者通过观察响应时间、报错文案、状态码的差异,来判断某个邮箱是否已注册。防御要点是让「用户不存在」和「密码错误」的响应完全一致——包括返回内容和耗时。上面的 DUMMY_PASSWORD_HASH 就是为了让两种情况都执行一次密码哈希比较,拉平耗时。
登出
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 授权码流程(authorization-code flow):
- 生成一次性随机
state参数——防止回调上的 CSRF。 - 生成 PKCE 的
code_verifier/code_challenge对——防御授权码拦截。 - 把两者都存放在一个绑定到这次尝试的短期签名 cookie 中。
// 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`,
})
},
)在回调 handler 中:
- 读取 cookie,验证其签名,并提取
state+verifier。 - 把 cookie 中的 state 与
state查询参数比较。如果不匹配,中止。 - 用授权码换取访问令牌(access token),同时发送
code_verifier。 - 获取用户资料,查找/创建本地用户记录,签发会话。
- 清除 OAuth cookie。
如果这些检查中有任何一项失败,说明该请求并非来自你的 startOAuth,必须被拒绝。
译者注:为什么需要 state 和 PKCE 两个机制?
state 防的是回调伪造:没有它,攻击者可以诱导受害者浏览器访问一个伪造的授权回调 URL。PKCE 防的是授权码被拦截:即使攻击者偷到了授权码,由于没有 code_verifier,也无法用它换取令牌。两者防御的攻击面不同,标准做法是同时使用。
密码重置:挫败用户枚举
重置端点绝不能告诉调用者某个邮箱是否已注册。返回 200 vs 404——甚至不同的文案——都会向任何能访问该端点的人泄露用户是否存在。
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)
}
// Same response, same body, regardless of existence.
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')
// 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.ts 的全局 requestMiddleware 中,让它对每个非 GET 请求运行,包括服务器路由和 SSR。
对认证端点限流
没有限流的登录端点会成为撞库(credential-stuffing)攻击的目标。用滑动窗口或令牌桶(token bucket),按 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 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 }),
])
// ...会话轮换
每当用户的权限发生变化——登录、登出、改密码、授予角色——都销毁旧会话并签发新会话。这能中和会话固定攻击(session-fixation):攻击者在权限变更之前把自制的会话 ID 植入受害者浏览器。
// 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)按请求读取 Cookie 与环境变量,而不是在模块作用域
模块作用域的读取在两个维度上都是错的:
- 安全:它们可能被内联进客户端打包产物。
- 边缘运行时上的正确性:Cloudflare Workers(和其他平台)在请求时才注入环境变量。模块级读取在任何请求存在之前就运行了,即使服务端也会得到
undefined。
// ❌ 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)
}完整规则见执行模型:模块作用域读取 process.env。
另见
- 认证总览——在合作伙伴方案、开源库和 DIY 之间做选择。
- Router 认证路由指南——路由侧的指南。
- 服务器函数——认证所在的 RPC 原语。
- 中间件——组合
authMiddleware。 - OWASP Cheat Sheets — Authentication、Session Management、CSRF。
- MDN — Set-Cookie。