关于 auth-server-primitives
面向 TanStack Start 开发者的服务端身份验证指南,涵盖会话 Cookie、OAuth、CSRF 防护和速率限制,用于保护函数、路由及账户流程。
主要功能
- 01会话 Cookie 处理
- 02身份验证中间件
- 03OAuth state 与 PKCE
- 04CSRF 防护
- 05速率限制与会话轮换
适用场景
- 01保护服务端函数中的私有数据
- 02构建登录与注销流程
- 03加强密码重置端点防护
- 04限制身份验证尝试
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ tanstack/ router --skill auth-server-primitives安装后在 Claude Code 中输入/auth-server-primitives确认已加载。
详细介绍
Auth Server Primitives
此技能涵盖身份验证的服务端部分:会话存储、Cookie 签发、OAuth 流程、密码重置加固、CSRF、速率限制。有关路由部分(_authenticated 布局、beforeLoad 重定向、RBAC 检查),请参阅 @tanstack/router-core#router-core/auth-and-guards。
关键:首先保护数据/API 边界。访问私有数据的服务端函数、服务端路由及其他 API 端点,必须在处理程序内部或中间件中强制执行身份验证。路由守卫用于路由 UX,不是数据安全边界。 关键:验证客户端提供的标识符的_格式_(
z.string().uuid().parse(...))并不等于授权。解析后的 UUID 仍然属于_某个_租户——使用它之前,必须根据会话主体重新检查成员资格。 关键:在.handler()或中间件.server()中读取会话/Cookie,不要在模块作用域读取。模块级读取发生在请求到来之前(在 Cloudflare Workers 上也无法正常工作)。
生产环境检查清单
- 对所有读取或写入用户、租户或账户私有数据的服务端函数、服务端路由或 API 端点强制执行身份验证。使用路由
beforeLoad改善页面 UX,而不要将其作为数据边界。 - 对所有接受输入的服务端函数使用
.validator()。 - 将会话存储在
HttpOnly、Secure、SameSiteCookie 中。不要将会话令牌存储在localStorage或sessionStorage中。 - 使用 bcrypt、scrypt 或 Argon2 哈希密码。对于不存在的用户,使用虚拟哈希进行验证,并返回相同的登录/重置消息。
- 对登录、注册和密码重置端点进行速率限制。
- 对非 GET 服务端函数和服务端路由使用 CSRF 或同源保护。
- 记录身份验证事件并监控失败情况。
- 测试对受保护服务端函数的未认证直接调用;它们应在返回数据前拒绝请求。
- 遵循匿名重定向,并检查其 HTML 和序列化状态。登录页和未授权页面不得提及受保护的用户、租户或记录。
会话 Cookie
推荐使用 HTTP 专用 Cookie 存储会话,其中包含不透明的会话 ID(由服务端查询)或签名/加密令牌。Cookie 标志很重要——请全部设置。
// 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
}
使用 setCookie 写入 Cookie(如果 Cookie 已由某个库序列化,则使用 appendResponseHeader('set-cookie', serialized))。不要为此使用 setResponseHeader('Set-Cookie', ...):它会替换传出响应中的所有 Cookie,包括其他中间件设置的 Cookie 或处理程序返回的 Cookie。
标志说明:
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 永远有效。应配合服务端会话轮换。
将会话查询作为中间件
使用中间件集中加载会话,使每个受保护的处理程序都能获取类型化会话:
// 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 } })
})
路由守卫无法保护这些函数。 带有
beforeLoad重定向的createFileRoute('/_authenticated/orders')并不能保护getMyOrders——无论用户是否访问过该路由,都可以直接调用 RPC。对每个需要身份验证的服务端函数应用authMiddleware(或在.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 }
})
OAuth:state + PKCE
对于 OAuth 授权码流程,生成一次性 state(用于防范 CSRF)和 PKCE 验证器(用于防范授权码拦截)。将两者存储在短时有效的签名 Cookie 中,并将其关联到这次确切的登录尝试。
// 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`,
})
},
)
在回调处理器中,验证 Cookie 中的 state 与返回的 state 匹配,并使用验证器交换授权码。如果 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)
}
// 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 }
})
切勿:
- 如果用户存在则返回 200,不存在则返回 404。
- 使用不同的消息(“我们已发送链接”与“未找到帐户”)。
- 用户不存在时跳过处理(这会造成时间泄露,可通过网络测量)。
非 GET RPC 的 CSRF 防护
会话 Cookie 上的 SameSite=Lax 可阻止大多数跨站 CSRF POST/PUT/DELETE 请求。有两种情况需要额外防护:
- 会修改数据的顶级 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 中的全局请求中间件,以覆盖每个非 GET 请求,包括服务器路由和 SSR。
对身份验证端点进行速率限制
没有速率限制的登录端点会成为凭证填充攻击的目标。按 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()
})
}
// On the login server function:
export const login = createServerFn({ method: 'POST' }).middleware([
rateLimitMiddleware({ key: 'login', max: 5, windowMs: 60_000 }),
])
// ...
权限变更时轮换会话
每当用户权限发生变化——登录、注销、角色变更、密码变更——销毁旧会话并签发新会话。这可以消除会话固定攻击:攻击者会在受害者登录前,将自己的会话 ID 植入受害者的浏览器。
// 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 } })
})
严重:将格式验证视为授权
解析后的 UUID 代表_某个_工作区,并不代表_已授权的_工作区。
// 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 都必须返回相同的响应正文。
高:在模块作用域读取 Cookie/env
// 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)
}
在 Cloudflare Workers 和其他边缘运行时中,由于 env 是按请求注入的,即使在服务器上,模块级读取也会得到 undefined。参见 start-core/execution-model。
中:会话长期有效且不轮换
永不轮换的会话令牌,实际上就是长期有效的凭证。请在登录、注销、密码变更以及角色/权限变更时轮换会话。
交叉引用
@tanstack/router-core#router-core/auth-and-guards— 路由方面:_authenticated布局、beforeLoad、redirect、RBAC 检查。- start-core/server-functions — 如何公开 RPC(以及路由守卫为何无法覆盖这些 RPC)。
- start-core/middleware — 如何组合
authMiddleware等中间件。 - start-core/execution-model — 为什么在模块级读取 env/密钥是错误的。