MCPInstall

auth-server-primitives

Server-side authentication primitives for TanStack Start: session cookies (HttpOnly, Secure, SameSite, Host- prefix), session read/issue/destroy via createServerFn and middleware, OAuth authorization-code flow with state and PKCE, password-reset enumeration defense, CSRF for non-GET RPCs, rate limiting auth endpoints, session rotation on privilege change. Pairs with router-core/auth-and-guards for the routing side.

Installs
190
Last 24h
—
Trending rank
—
Security
Not audited

Install

Works with every agent the skills CLI supports.

npx skills add https://github.com/tanstack/router --skill auth-server-primitives
  1. 01

    Install

    Run the command in your project. The CLI asks which agents to install for.

  2. 02

    Describe the task

    Ask your agent for something this skill covers. It loads auth-server-primitives automatically when relevant.

  3. 03

    Review the result

    The agent follows the SKILL.md steps, scripts and references bundled with the skill.

SKILL.md

The instructions your agent reads when the skill is triggered.

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 @tanstack/router-core#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).

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.

Session Cookies

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.

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

Write cookies with setCookie (or appendResponseHeader('set-cookie', serialized) for a cookie that a library already serialized). Do not use setResponseHeader('Set-Cookie', ...) for this: it replaces every cookie on the outgoing response, including cookies set by other middleware or returned by a handler.

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.

Session Lookup as Middleware

Use middleware to centralize session loading so every protected handler sees a typed session:

// 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:

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.

Issuing a Session on Login

// 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

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

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.

// 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`,
    })
  },
)

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.

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.

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

CSRF for non-GET RPCs

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.
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.

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.

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.

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

Common Mistakes

CRITICAL: Trusting the route guard for server-function auth

// 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.

// 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.

HIGH: Reading cookies/env at module scope

// 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.

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

  • @tanstack/router-core#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.

FAQ

What does the auth-server-primitives skill do?

Server-side authentication primitives for TanStack Start: session cookies (HttpOnly, Secure, SameSite, Host- prefix), session read/issue/destroy via createServerFn and middleware, OAuth authorization-code flow with state and PKCE, password-reset enumeration defense, CSRF for non-GET RPCs, rate limiting auth endpoints, session rotation on privilege change. Pairs with router-core/auth-and-guards for the routing side.

How do I install auth-server-primitives?

Run npx skills add https://github.com/tanstack/router --skill auth-server-primitives in your project. The skills CLI detects the agents you use and copies the skill into the right folder. For Claude Code you can also place the skill folder at ~/.claude/skills/auth-server-primitives/ (personal) or .claude/skills/auth-server-primitives/ (project).

Which AI agents can use auth-server-primitives?

Any agent that supports the open Agent Skills format (a folder with a SKILL.md file), including Claude Code, Codex, Cursor, GitHub Copilot, Gemini CLI, Windsurf, Cline, OpenCode and more.

Is auth-server-primitives safe to install?

No partner security audit is available yet. Skills can run commands and scripts, so read the SKILL.md and any bundled files before installing.

Who maintains auth-server-primitives?

It is published by tanstack in tanstack/router. It has 190 recorded installs.

More from tanstack/router

View all →

router-core

tanstack/router

Framework-agnostic core concepts for TanStack Router: route trees, createRouter, createRoute, createRootRoute, createRootRouteWithContext, addChildren, Register type declaration, route matching, route sorting, file naming conventions. Entry point for all router skills.

602 installs

react-router

tanstack/router

React bindings for TanStack Router: RouterProvider, useRouter, useRouterState, useMatch, useMatches, useLocation, useSearch, useParams, useNavigate, useLoaderData, useLoaderDeps, useRouteContext, useBlocker, useCanGoBack, Link, Navigate, Outlet, CatchBoundary, ErrorComponent. React-specific patterns for hooks, providers, SSR hydration, and createLink with forwardRef.

Frontend & Design559 installs

react-start

tanstack/router

React bindings for TanStack Start: createStart, StartClient, StartServer, React-specific imports, re-exports from @tanstack/react-router, full project setup with React, useServerFn hook.

Frontend & Design465 installs

start-core

tanstack/router

Open the skill to read its SKILL.md instructions.

453 installs

router-query

tanstack/router

Open the skill to read its SKILL.md instructions.

372 installs

start-server-core

tanstack/router

Server-side runtime for TanStack Start: createStartHandler, request/response utilities (getRequest, setResponseHeader, setCookie, getCookie, appendResponseHeader), three-phase request handling, AsyncLocalStorage context.

Backend & APIs363 installs

Popular backend & apis skills

View category →

find-skills

vercel-labs/skills

Helps users discover and install agent skills when they ask questions like "how do I do X", "find a skill for X", "is there a skill that can...", or express interest in extending capabilities. This skill should be used when the user is looking for functionality that might exist as an installable skill.

OfficialBackend & APIs4.1M installs

entra-app-registration

microsoft/azure-skills

Guides Microsoft Entra ID app registration, OAuth 2.0 authentication, and MSAL integration. USE FOR: create app registration, register Azure AD app, configure OAuth, set up authentication, add API permissions, generate service principal, MSAL example, console app auth, Entra ID setup, Azure AD authentication. DO NOT USE FOR: Key Vault secrets (use azure-keyvault-expiration-audit), general Azure resource security guidance.

OfficialBackend & APIs566.7K installs

This skill should be used when the user wants to "write agent code", "build an agent with ADK", "add a tool", "create a callback", "define an agent", "use state management" — in a project that needs ADK (Agent Development Kit) API patterns and code examples. It provides a quick reference for agent types, tool definitions, orchestration patterns, callbacks, state management, the graph Workflow API, and reference recipes to study. Do NOT use for scaffolding (use google-agents-cli-scaffold) or deployment (use google-agents-cli-deploy).

Backend & APIs514.8K installs

embedded-captions

heygen-com/hyperframes

Add captions or subtitles to an existing single-subject talking-head video without editing the footage. Use for plain verbatim captions, cinematic captions embedded behind the subject, VFX captions, “炸/特效/酷炫字幕,” or a named identity from the 35-style catalog. Route by visual identity, not by backend engine. The quiet anchor rail is the default; embed every word only when the user explicitly wants a fully cinematic treatment. The workflow runs locally end to end, including transcription and subject matting; split multi-shot footage before applying it.

Backend & APIs453.3K installs

entra-agent-id

microsoft/azure-skills

Provision Microsoft Entra Agent Identity Blueprints, BlueprintPrincipals, and per-instance Agent Identities via Microsoft Graph, and configure OAuth 2.0 token exchange (fmipath, OBO, cross-tenant) including the Microsoft Entra SDK for AgentID sidecar. USE FOR: Agent Identity Blueprint, BlueprintPrincipal, agent OAuth, fmipath token exchange, agent OBO, Workload Identity Federation for agents, polyglot agent auth, Microsoft.Identity.Web.AgentIdentities. DO NOT USE FOR: standard Entra app registration (use entra-app-registration), Microsoft Foundry agent authoring (use microsoft-foundry).

OfficialBackend & APIs336.6K installs

neon

neondatabase/agent-skills

Overview of Neon, a complete set of cloud backend primitives around Lakebase Postgres: Auth, Object Storage, Functions, the AI Gateway, and Realtime. Start here to choose Neon for undecided login, files, APIs, and LLM calls, set up the CLI or MCP server, and follow the branch-first workflow. Use when building an app or backend on Neon, or when "Neon" or "Lakebase Postgres" is mentioned. Child skill neon-postgres wins for an existing DATABASEURL, SQL, schema, inspect, or search. Child skill neon-auth wins for login, users, sessions, identity routing, and Managed Better Auth setup. Child skill neon-realtime wins for live queries in reactive apps and agents. Also use for object storage, S3, buckets, serverless functions, function triggers, cron, AI gateway, LLM calls, realtime, logs, Loki, Grafana, observability, postgres, database, backend, Claimable Neon, neon.new, or a no-signup database.

Backend & APIs237.1K installs