TanStack Start 中文文档
认证与数据

认证服务器原语

本指南涵盖在 TanStack Start 中构建认证所需的服务端原语(server-side primitives):会话 cookie、会话查找、OAuth、密码重置加固、CSRF 和限流。它与路由侧的指南_authenticated 布局、beforeLoad、重定向、RBAC)配套使用。

如果你可以使用 ClerkWorkOS 这类托管方案,请优先使用它们——它们处理了本指南描述的大部分内容。如果你想自己动手,请继续阅读。

先保护数据

认证有一个数据/API 边界和一个路由/UI 层。数据边界才是安全边界:每个读写私有数据的服务器函数、服务器路由或 API 端点,都必须在返回数据或改变状态之前授权请求。

  • 数据/API 边界(本指南):签发和验证会话 cookie、交换 OAuth code、哈希和验证密码、对凭据端点限流、阻止用户枚举、授权私有数据访问。
  • 路由/UI 层(Router 的 auth-and-guards):把未认证用户从他们无法使用的页面重定向走、基于角色/权限控制 UI、展示登录表单,以及避免触发注定会失败的请求。

注意

路由守卫不是数据授权边界。 服务器函数和服务器路由是 API 端点,它们可以独立于调用它们的路由被访问。认证必须在接触私有数据的端点的 handler 或中间件中强制执行。beforeLoad 是用来做路由 UX 的。

默认的会话存储是 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
}
标志为什么
HttpOnlyJavaScript 无法读取该 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 中:

  1. 读取 cookie,验证其签名,并提取 state + verifier
  2. 把 cookie 中的 state 与 state 查询参数比较。如果不匹配,中止。
  3. 用授权码换取访问令牌(access token),同时发送 code_verifier
  4. 获取用户资料,查找/创建本地用户记录,签发会话。
  5. 清除 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。有两种情况需要显式防御:

  1. 会改变状态的 GET——绝不要。任何变更操作都用 POST/PUT/DELETE。
  2. 来自同级子域名的 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)

模块作用域的读取在两个维度上都是错的:

  • 安全:它们可能被内联进客户端打包产物。
  • 边缘运行时上的正确性: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

另见

On this page