auth-server-primitives

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

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.
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).
本技能涵盖认证的服务端部分:会话存储、Cookie 签发、OAuth 流程、密码重置加固、CSRF 防护、限流。关于路由端部分(
_authenticated
布局、
beforeLoad
重定向、RBAC 校验),请参考 router-core/auth-and-guards。
重要提示:请优先保护数据/API 边界。涉及私有数据的服务端函数、服务端路由和其他 API 端点必须在处理函数内部或 middleware 中强制执行认证。路由守卫属于路由层面的用户体验控制,而非数据安全边界。 重要提示:验证客户端提供的标识符的格式(如
z.string().uuid().parse(...)
)不等于授权。解析通过的 UUID 仅代表某个租户的标识有效——在使用前,必须结合会话主体重新校验用户的成员权限。 重要提示:请在
.handler()
或 middleware 的
.server()
中读取会话/Cookie,不要在模块作用域读取。模块级别的读取会在请求产生前执行(在 Cloudflare Workers 环境中还会返回 undefined)。

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.
  • 在所有读写私有用户、租户或账户数据的服务端函数、服务端路由或 API 端点中强制执行认证。路由的
    beforeLoad
    仅用于页面体验控制,不能作为数据安全边界。
  • 对所有接收输入的服务端函数使用
    .validator()
    进行校验。
  • 将会话存储在
    HttpOnly
    、
    Secure
    、
    SameSite
    属性的 Cookie 中。不要将会话令牌存储在
    localStorage
    或
    sessionStorage
    中。
  • 使用 bcrypt、scrypt 或 Argon2 对密码进行哈希处理。对于不存在的用户,使用虚拟哈希进行验证,并返回与正常情况相同的登录/重置消息。
  • 对登录、注册和密码重置端点进行限流。
  • 对非 GET 请求的服务端函数和服务端路由使用 CSRF 或同源保护。
  • 记录认证事件并监控失败情况。
  • 测试直接未认证调用受保护的服务端函数的场景;它们应当在返回数据前就被拒绝。
  • 跟踪匿名用户的重定向流程,检查返回的 HTML 和序列化状态。登录页和未授权页面不得泄露受保护的用户、租户或记录的相关信息。

Session Cookies

会话 Cookie

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 {
  getRequestHeader,
  setResponseHeader,
} from '@tanstack/react-start/server'

const SESSION_COOKIE = '__Host-session' // __Host- prefix binds to the exact origin + path '/'
const ONE_DAY = 60 * 60 * 24

export function setSessionCookie(token: string) {
  setResponseHeader(
    'Set-Cookie',
    [
      `${SESSION_COOKIE}=${token}`,
      `HttpOnly`, // not readable from JS — defeats XSS exfiltration
      `Secure`, // HTTPS only (required for __Host- prefix)
      `SameSite=Lax`, // sent on top-level navigations, blocks most CSRF
      `Path=/`, // required for __Host- prefix
      `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
}
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.
推荐的会话存储方式是 HttpOnly Cookie,内容可以是不透明的会话 ID(配合服务端查询),也可以是经过签名/加密的令牌。Cookie 的各项属性非常重要——请全部设置。
tsx
// src/server/session.ts
import {
  getRequestHeader,
  setResponseHeader,
} from '@tanstack/react-start/server'

const SESSION_COOKIE = '__Host-session' // __Host- prefix binds to the exact origin + path '/'
const ONE_DAY = 60 * 60 * 24

export function setSessionCookie(token: string) {
  setResponseHeader(
    'Set-Cookie',
    [
      `${SESSION_COOKIE}=${token}`,
      `HttpOnly`, // not readable from JS — defeats XSS exfiltration
      `Secure`, // HTTPS only (required for __Host- prefix)
      `SameSite=Lax`, // sent on top-level navigations, blocks most CSRF
      `Path=/`, // required for __Host- prefix
      `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/PUT/DELETE 请求的 CSRF 攻击。如果可以接受跨站 GET 导航失效的情况,对于最高安全级别的流程可使用
    Strict
    。
  • __Host-
    前缀 — 将 Cookie 绑定到确切的源(不能有 Domain 属性,Path 必须为
    /
    ,必须设置 Secure)。可防止子域名接管伪造会话 Cookie。
  • Path=/
    —
    __Host-
    前缀的要求。
  • Max-Age
    — 有限的生命周期,避免被盗的 Cookie 永久有效。配合服务端会话轮换使用。

Session Lookup as Middleware

基于 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.
使用 middleware 集中处理会话加载,这样每个受保护的处理函数都能获取到带类型的会话:
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 } })
  },
)
将其附加到所有需要登录用户的服务端函数上:
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 } })
  })
路由守卫无法覆盖这一点。 带有
beforeLoad
重定向的
createFileRoute('/_authenticated/orders')
并不能保护
getMyOrders
——无论用户是否访问过该路由,RPC 都可以直接被调用。请在所有需要认证的服务端函数上应用
authMiddleware
(或在
.handler()
内部重新校验)。

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 }
  })
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 }
  })
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

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 {
  getRequestHeader,
  setResponseHeader,
} 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(),
    )

    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`,
    })
  },
)
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
.
对于 OAuth 授权码流程,请生成一次性的
state
(用于 CSRF 防护)和 PKCE 验证器(用于防止授权码被拦截)。将两者都存储在短期有效的签名 Cookie 中,与本次登录尝试一一对应。
tsx
// 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' // 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(),
    )

    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`,
    })
  },
)
在回调处理函数中,验证 Cookie 中的 state 与返回的 state 是否一致,然后使用验证器交换授权码。如果 state 缺失或不匹配,请直接中止请求——该请求并非来自你的
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).
当用户请求密码重置时,不要让响应的格式或耗时泄露该邮箱是否已注册。
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 }
  })
请勿执行以下操作:
  • 邮箱存在返回 200,不存在返回 404。
  • 返回不同的消息(如“我们已发送链接”和“未找到账户”)。
  • 用户不存在时跳过处理流程(会造成时序泄露——可通过网络请求耗时探测)。

CSRF for non-GET RPCs

非 GET RPC 的 CSRF 防护

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.
会话 Cookie 的
SameSite=Lax
属性可阻止大多数跨站 POST/PUT/DELETE 请求的 CSRF 攻击。但有两种情况需要额外防护:
  1. 会修改数据的顶层 GET 导航——绝对不要这么做。所有修改操作都应使用 POST/PUT/DELETE。
  2. 来自同级子域名页面的 POST 请求——
    SameSite=Lax
    无法阻止这种情况;请在 middleware 中校验
    Origin
    请求头是否与你的应用源一致。
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()
})
将其附加到
src/start.ts
中的全局请求 middleware 上,这样它就能覆盖所有非 GET 请求,包括服务端路由和 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 }),
])
// ...
没有限流的登录端点是凭证撞库的目标。请使用滑动窗口按 IP 限流(理想情况下还应按账户限流)。
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)
每当用户的权限发生变更时——登录、登出、角色变更、密码修改——销毁旧会话并签发新会话。这可以防范会话固定攻击,即攻击者在用户登录前将自己的会话 ID 植入受害者浏览器的攻击方式。
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 } })
  })
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])
  .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)
  })
解析通过的 UUID 仅代表某个工作空间的标识有效,不代表用户有权限访问该工作空间。
tsx
// 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)
  })

HIGH: Returning different responses based on email existence

高危错误:根据邮箱是否存在返回不同响应

Already covered above —
requestPasswordReset
must return the same body regardless of whether the email matches a user.
上文已经提到——无论邮箱是否对应用户,
requestPasswordReset
都必须返回相同的响应体。

HIGH: Reading cookies/env at module scope

高危错误:在模块作用域读取 Cookie/环境变量

tsx
// 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)
}
On Cloudflare Workers and other edge runtimes, the module-level read evaluates to
undefined
even on the server because env is injected per-request. See start-core/execution-model.
tsx
// 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 和其他边缘运行时中,由于环境变量是按请求注入的,模块级别的读取即使在服务端也会返回
undefined
。请参考 start-core/execution-model。

MEDIUM: Long-lived sessions with no rotation

中危问题:长期有效且从不轮换的会话

A session token that never rotates is functionally a long-lived credential. Rotate on login, logout, password change, and role/permission change.
从不轮换的会话令牌实际上就是长期有效的凭证。请在登录、登出、密码修改和角色/权限变更时进行会话轮换。

Cross-References

相关参考

  • router-core/auth-and-guards — the routing side:
    _authenticated
    layout,
    beforeLoad
    ,
    redirect
    , RBAC checks.
  • start-core/server-functions — how to expose RPCs (and how the route guard does NOT cover them).
  • start-core/middleware — composing
    authMiddleware
    and others.
  • start-core/execution-model — why module-level env/secret reads are wrong.
  • router-core/auth-and-guards — 路由端部分:
    _authenticated
    布局、
    beforeLoad
    、
    redirect
    、RBAC 校验。
  • start-core/server-functions — 如何暴露 RPC(以及为什么路由守卫无法覆盖它们)。
  • start-core/middleware — 如何组合
    authMiddleware
    和其他 middleware。
  • start-core/execution-model — 为什么模块级别的环境变量/密钥读取是错误的。