In this article
May 28, 2026
May 28, 2026

TanStack Start authentication: A developer's guide for 2026

Your beforeLoad guard does not protect your server functions. A complete guide to authentication in TanStack Start, from server functions and sessions to enterprise SSO.

Explore with AI
Open in ChatGPT
Open in Claude
Open in Perplexity

TanStack Start is the full-stack React framework built on TanStack Router, and it has a fundamentally different mental model for authentication than React Router, Next.js, or any other React framework.

The core difference: TanStack Start treats server functions as the primary security boundary, not routes. Every createServerFn is an RPC endpoint that can be called via a direct HTTP POST, regardless of which route rendered the UI that triggered the call. A beforeLoad guard on a route protects the page experience, but it does not stop a request from hitting the server function directly. If your authentication logic lives only in beforeLoad, your data is exposed.

This guide covers authentication in TanStack Start from the ground up: how server functions, beforeLoad, sessions, and middleware work together to form a complete auth system, where the security boundaries actually are, and how to implement authentication correctly.

How authentication works in TanStack Start

TanStack Start provides four primitives that together form your authentication architecture. Understanding what each one does (and what it does not do) is the most important thing in this guide.

Server functions: the security boundary

Server functions created with createServerFn are the foundation of authentication in TanStack Start. They run exclusively on the server, have access to request headers and cookies, and can interact with your database and secrets directly.

  
import { createServerFn } from '@tanstack/react-start'
import { getRequestHeaders } from '@tanstack/react-start/server'

export const getUser = createServerFn({ method: 'GET' }).handler(
  async () => {
    const headers = getRequestHeaders()
    const session = await getSessionFromCookie(headers)

    if (!session) {
      return null
    }

    return await db.users.findById(session.userId)
  }
)
  

The critical detail: every server function is an HTTP endpoint. When you write createServerFn({ method: 'POST' }), TanStack Start registers an RPC route that accepts POST requests. Anyone who knows the URL can call it directly, bypassing your routes and components entirely. This means authentication must be enforced inside each server function that handles sensitive data or operations, not just at the route level.

  
// This server function is callable via direct POST
// regardless of what route renders the calling UI
export const deleteAccount = createServerFn({ method: 'POST' }).handler(
  async () => {
    // If this function doesn't verify the session itself,
    // anyone can call it directly
    const session = await requireAuth()
    await db.users.delete(session.userId)
    return { success: true }
  }
)
  

beforeLoad: the route guard

beforeLoad runs before a route's component renders and before its loader executes. It is the idiomatic place to check authentication at the route level and redirect unauthenticated users to login.

  
import { createFileRoute, redirect } from '@tanstack/react-router'
import { getUser } from '../server/auth.functions'

export const Route = createFileRoute('/dashboard')({
  beforeLoad: async () => {
    const user = await getUser()

    if (!user) {
      throw redirect({
        to: '/login',
        search: { redirect: '/dashboard' },
      })
    }

    return { user }
  },
  component: Dashboard,
})

function Dashboard() {
  const { user } = Route.useRouteContext()
  return <h1>Welcome, {user.name}</h1>
}
  

beforeLoad provides a good user experience: unauthenticated users are redirected before they see any protected content. And the data returned from beforeLoad is available through the route context, giving your components type-safe access to the authenticated user without prop drilling or global state.

But beforeLoad is not a security boundary for server functions. It protects the page, not the endpoint. If a protected route calls deleteAccount() and deleteAccount doesn't verify the session itself, an attacker can call that function directly without ever loading the route.

Middleware on server functions

TanStack Start supports middleware that runs before a server function's handler. This is the cleanest way to enforce authentication across multiple server functions without repeating session-checking code in every handler.

  
import { createMiddleware, createServerFn } from '@tanstack/react-start'
import { getRequestHeaders } from '@tanstack/react-start/server'

// Auth middleware that verifies the session
const authMiddleware = createMiddleware().server(async ({ next }) => {
  const headers = getRequestHeaders()
  const session = await getSessionFromCookie(headers)

  if (!session) {
    throw new Error('Unauthorized')
  }

  const user = await db.users.findById(session.userId)

  if (!user) {
    throw new Error('Unauthorized')
  }

  return next({ context: { user } })
})

// Server function protected by middleware
export const getOrders = createServerFn({ method: 'GET' })
  .middleware([authMiddleware])
  .handler(async ({ context }) => {
    // context.user is typed and guaranteed to exist
    return await db.orders.findByUserId(context.user.id)
  })
  

Middleware composes cleanly. You can layer auth, logging, rate limiting, and permission checks:

  
const adminMiddleware = createMiddleware()
  .middleware([authMiddleware])
  .server(async ({ next, context }) => {
    if (context.user.role !== 'admin') {
      throw new Error('Forbidden')
    }
    return next({ context })
  })

export const deleteUser = createServerFn({ method: 'POST' })
  .middleware([adminMiddleware])
  .inputValidator(z.object({ userId: z.string() }))
  .handler(async ({ data, context }) => {
    // context.user is guaranteed to be an admin
    await db.users.delete(data.userId)
    return { success: true }
  })
  

The middleware context is fully typed through TanStack Start's inference system. When you pass { user } from authMiddleware, downstream middleware and handlers know the exact type of context.user without manual type annotations. This is one of TanStack Start's genuine advantages: the type system catches auth mistakes at compile time.

Sessions

TanStack Start uses vinxi/http for low-level session management. Sessions are stored in cookies and provide a simple API for reading and writing session data.

  
import { useSession } from 'vinxi/http'

type SessionData = {
  userId: string
  email: string
}

export function useAppSession() {
  return useSession<SessionData>({
    password: process.env.SESSION_SECRET!,
  })
}
  

The useSession helper from vinxi encrypts and signs the session cookie using your secret. The session data lives entirely in the cookie, similar to React Router's createCookieSessionStorage. This means no external session store is needed for simple payloads, but you are limited to 4KB of session data.

For authentication flows, you read and write sessions inside server functions:

  
export const loginFn = createServerFn({ method: 'POST' })
  .inputValidator(z.object({
    email: z.string().email(),
    password: z.string(),
  }))
  .handler(async ({ data }) => {
    const user = await db.users.findByEmail(data.email)

    // Always verify the hash even if user doesn't exist
    // to prevent timing-based user enumeration
    const hashToCheck = user?.passwordHash ?? DUMMY_HASH
    const valid = await verifyPasswordHash(data.password, hashToCheck)

    if (!user || !valid) {
      return { error: 'Invalid credentials' }
    }

    const session = await useAppSession()
    await session.update({ userId: user.id, email: user.email })

    throw redirect({ to: '/dashboard' })
  })

export const logoutFn = createServerFn({ method: 'POST' }).handler(
  async () => {
    const session = await useAppSession()
    await session.clear()
    throw redirect({ to: '/login' })
  }
)
  

The double-guard pattern

Diagram showing two paths to a TanStack Start server function. Path 1 goes through the route, where beforeLoad checks the session before the component calls the server function. Path 2 shows a direct HTTP POST that bypasses beforeLoad entirely and hits the server function directly. A callout box explains that both levels of protection are required: beforeLoad on the route for user experience, and middleware on the server function for data security.

The architecture described above leads to a pattern that is specific to TanStack Start and worth naming explicitly: every protected operation needs guards at two levels.

Level 1: Route guard via beforeLoad. This protects the user experience. Unauthenticated users are redirected to login before seeing protected pages or triggering loaders.

Level 2: Server function guard via middleware. This protects the data and operations. Every server function that reads or writes sensitive data verifies the session itself, because it can be called directly as an HTTP endpoint.

  
// Level 1: Route guard
export const Route = createFileRoute('/_protected')({
  beforeLoad: async () => {
    const user = await getUser()
    if (!user) {
      throw redirect({ to: '/login' })
    }
    return { user }
  },
  component: () => <Outlet />,
})

// Level 2: Server function guard
export const updateProfile = createServerFn({ method: 'POST' })
  .middleware([authMiddleware])  // Enforced independently of the route
  .inputValidator(z.object({ name: z.string().min(1) }))
  .handler(async ({ data, context }) => {
    await db.users.update(context.user.id, { name: data.name })
    return { success: true }
  })
  

Omitting either level creates a gap. Without the route guard, unauthenticated users see protected UI before the server function rejects their request (bad UX). Without the server function guard, the data is accessible to anyone who calls the endpoint directly (security vulnerability).

Protected layout routes

TanStack Router supports layout routes, which are routes that render shared UI (like a sidebar or navigation) and nest child routes inside an <Outlet />. This is the idiomatic way to protect groups of routes with a single auth check.

By convention, layout routes prefixed with an underscore (_protected) are not reflected in the URL:

  
src/routes/
  _protected.tsx          # Layout with beforeLoad auth check
  _protected/
    dashboard.tsx         # /dashboard
    settings.tsx          # /settings
    orders.tsx            # /orders
  login.tsx               # /login (not protected)
  
  
// _protected.tsx
import { createFileRoute, redirect, Outlet } from '@tanstack/react-router'
import { getUser } from '../server/auth.functions'

export const Route = createFileRoute('/_protected')({
  beforeLoad: async ({ location }) => {
    const user = await getUser()
    if (!user) {
      throw redirect({
        to: '/login',
        search: { redirect: location.href },
      })
    }
    return { user }
  },
  component: () => (
    <div className="flex">
      <Sidebar />
      <main>
        <Outlet />
      </main>
    </div>
  ),
})
  

Every route nested under _protected/ inherits the auth check. Child routes can access the authenticated user through the route context:

  
// _protected/dashboard.tsx
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/_protected/dashboard')({
  component: Dashboard,
})

function Dashboard() {
  // Type-safe access to the user from the parent layout's beforeLoad
  const { user } = Route.useRouteContext()
  return <h1>Welcome, {user.name}</h1>
}
  

This pattern centralizes the route-level auth check (Level 1 of the double-guard pattern) while keeping each server function responsible for its own auth (Level 2).

Type-safe authentication context

TanStack Router's type system is one of its strongest features, and it extends naturally to authentication. When beforeLoad returns data, that data becomes part of the route context with full type inference.

  
// Define your router context type
interface RouterContext {
  user: User | null
}

// Create the router with typed context
const router = createRouter({
  routeTree,
  context: { user: null },
})

// In beforeLoad, the return type updates the context
export const Route = createFileRoute('/_protected')({
  beforeLoad: async () => {
    const user = await getUser()
    if (!user) throw redirect({ to: '/login' })
    return { user } // TypeScript knows user is User, not User | null
  },
})

// In child components, the context is correctly typed
function Dashboard() {
  const { user } = Route.useRouteContext()
  // user is typed as User (not null), because beforeLoad
  // would have redirected if it were null
}
  

This compile-time safety catches mistakes that other frameworks only surface at runtime. If you refactor your auth context (renaming fields, changing types), TypeScript will flag every file that depends on it. In a large application with dozens of protected routes, this prevents an entire class of auth-related bugs.

Authentication implementation approaches

Approach 1: Server functions with session cookies

The most straightforward approach: use createServerFn for login, logout, and session verification, vinxi/http sessions for state, and beforeLoad for route protection.

  
// src/server/auth.functions.ts
import { createServerFn } from '@tanstack/react-start'
import { getRequestHeaders } from '@tanstack/react-start/server'
import { useSession } from 'vinxi/http'
import { z } from 'zod'
import bcrypt from 'bcrypt'

function useAppSession() {
  return useSession<{ userId: string }>({
    password: process.env.SESSION_SECRET!,
  })
}

export const getUser = createServerFn({ method: 'GET' }).handler(
  async () => {
    const session = await useAppSession()
    if (!session.data.userId) return null
    return await db.users.findById(session.data.userId)
  }
)

export const loginFn = createServerFn({ method: 'POST' })
  .inputValidator(z.object({
    email: z.string().email(),
    password: z.string(),
  }))
  .handler(async ({ data }) => {
    const user = await db.users.findByEmail(data.email)
    const hashToCheck = user?.passwordHash ?? DUMMY_HASH
    const valid = await bcrypt.compare(data.password, hashToCheck)

    if (!user || !valid) {
      return { error: 'Invalid credentials' }
    }

    const session = await useAppSession()
    await session.update({ userId: user.id })
    throw redirect({ to: '/dashboard' })
  })

export const logoutFn = createServerFn({ method: 'POST' }).handler(
  async () => {
    const session = await useAppSession()
    await session.clear()
    throw redirect({ to: '/login' })
  }
)
  

The login form uses a standard React form that calls the server function:

  
// src/routes/login.tsx
import { createFileRoute, useRouter } from '@tanstack/react-router'
import { useState } from 'react'
import { loginFn } from '../server/auth.functions'

export const Route = createFileRoute('/login')({
  component: Login,
})

function Login() {
  const router = useRouter()
  const [error, setError] = useState<string | null>(null)

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    const form = new FormData(e.currentTarget)
    const result = await loginFn({
      data: {
        email: form.get('email') as string,
        password: form.get('password') as string,
      },
    })
    if (result?.error) {
      setError(result.error)
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      {error && <p className="error">{error}</p>}
      <input type="email" name="email" required />
      <input type="password" name="password" required />
      <button type="submit">Log in</button>
    </form>
  )
}
  

This approach gives you complete control and no external dependencies beyond bcrypt and your database. You own the session management, the credential verification, and the entire auth flow. The cost is that you also own password resets, email verification, MFA, OAuth integration, and the ongoing security maintenance.

Approach 2: Auth libraries (Better Auth, Auth.js)

Better Auth and Auth.js both have TanStack Start integrations that handle more of the auth lifecycle for you.

Better Auth provides a TypeScript-first auth library with a TanStack Start integration. It handles email/password, OAuth providers, session management, and MFA. It provides a tanstackStartCookies plugin that automatically handles cookie setting in TanStack Start's server function context, and an ensureSession helper for protecting server functions.

Auth.js (formerly NextAuth) supports TanStack Start through its generic framework adapter. It provides OAuth and social login for 80+ providers, session management, and a callback-based API. The integration works through server routes that handle the OAuth callback flow.

Both libraries reduce the amount of auth code you write yourself, but they come with trade-offs to consider. They are younger integrations compared to their Next.js counterparts, with smaller communities and fewer TanStack-specific examples. Better Auth's TanStack Start integration is more mature; Auth.js support is more recent. Neither library provides enterprise SSO (SAML/OIDC), SCIM directory sync, audit logs, or compliance features. If you need those later, you will need a separate solution.

Approach 3: Managed authentication provider

The approaches above require you to run authentication infrastructure inside your application. Even with a library like Better Auth handling the mechanics, the session store, the OAuth state, and the security maintenance are your responsibility.

A managed provider handles all of this externally and integrates into TanStack Start through its server function and middleware model. The right provider plugs into createServerFn, works with TanStack Start's request middleware, and provides type-safe auth context that flows through your route tree.

WorkOS is a strong fit here. It provides @workos/authkit-tanstack-react-start, a first-party SDK built specifically for TanStack Start. The SDK provides request middleware that validates and refreshes sessions on every request, server helpers (getAuth(), signOut(), getSignInUrl()), client hooks (useAuth(), useAccessToken()), and a callback route handler.

The fastest way to get started is the WorkOS CLI:

  
npx workos@latest install
  

You can also set up the integration manually. Install the SDK:

  
npm install @workos/authkit-tanstack-react-start
  
  
# .env
WORKOS_CLIENT_ID="client_..."
WORKOS_API_KEY="sk_test_..."
WORKOS_REDIRECT_URI="http://localhost:3000/api/auth/callback"
WORKOS_COOKIE_PASSWORD="..."  # Min 32 characters
  

Register the AuthKit middleware in your Start configuration:

  
// src/start.ts
import { createStart } from '@tanstack/react-start'
import { authkitMiddleware } from '@workos/authkit-tanstack-react-start'

export const startInstance = createStart(() => ({
  requestMiddleware: [authkitMiddleware()],
}))
  

Set up the callback route:

  
// src/routes/api/auth/callback.ts
import { createFileRoute } from '@tanstack/react-router'
import { handleCallbackRoute } from '@workos/authkit-tanstack-react-start'

export const Route = createFileRoute('/api/auth/callback')({
  server: {
    handlers: {
      GET: handleCallbackRoute(),
    },
  },
})
  

Protect routes using getAuth() in beforeLoad:

  
// src/routes/_protected.tsx
import { createFileRoute, redirect, Outlet } from '@tanstack/react-router'
import { getAuth } from '@workos/authkit-tanstack-react-start'

export const Route = createFileRoute('/_protected')({
  beforeLoad: async () => {
    const auth = await getAuth()

    if (!auth.user) {
      const signInUrl = await getSignInUrl()
      throw redirect({ href: signInUrl })
    }

    return { user: auth.user }
  },
  component: () => <Outlet />,
})
  

The authkitMiddleware runs on every request, validating the session cookie and refreshing tokens transparently. getAuth() reads the validated session without re-checking on every call. The result is type-safe auth context that flows through your route tree with zero boilerplate.

For client components that need auth state, wrap your app in AuthKitProvider and use the useAuth() hook:

  
// src/router.tsx
import { AuthKitProvider } from '@workos/authkit-tanstack-react-start/client'

function App() {
  return (
    <AuthKitProvider>
      <RouterProvider router={router} />
    </AuthKitProvider>
  )
}
  
  
// In any client component
import { useAuth } from '@workos/authkit-tanstack-react-start/client'

function UserMenu() {
  const { user, isLoading } = useAuth()
  if (isLoading) return null
  if (!user) return <a href="/login">Sign in</a>
  return <span>{user.firstName}</span>
}
  

Beyond basic authentication, WorkOS provides enterprise SSO (SAML and OIDC), SCIM-based directory sync, organization management with multi-tenancy, audit logs, bot protection, and compliance features. These capabilities build on each other as your requirements grow, and the TanStack Start SDK integrates them through the same middleware and getAuth() pattern.

This approach makes the most sense for B2B software where enterprise customers will eventually require SSO, directory sync, or compliance certifications. Rather than building those features over months, you delegate them to a platform designed for that purpose and keep your team focused on your product.

Security considerations

Server functions are public endpoints

This is worth repeating because it is the most common security mistake in TanStack Start applications. Every createServerFn registers an HTTP endpoint. A beforeLoad redirect on the route does not prevent direct calls to the server function. Always enforce auth inside the server function itself, either with middleware or an inline check.

  
// Vulnerable: relies on route guard alone
export const getSensitiveData = createServerFn({ method: 'GET' }).handler(
  async () => {
    // No auth check here — callable directly
    return await db.secrets.findAll()
  }
)

// Secure: auth enforced on the server function
export const getSensitiveData = createServerFn({ method: 'GET' })
  .middleware([authMiddleware])
  .handler(async ({ context }) => {
    return await db.secrets.findByUserId(context.user.id)
  })
  

Input validation

TanStack Start supports .inputValidator() on server functions, which validates data before it reaches your handler. Always use it. Without input validation, an attacker can send unexpected types, shapes, or values to your server functions.

  
import { z } from 'zod'

export const updateProfile = createServerFn({ method: 'POST' })
  .middleware([authMiddleware])
  .inputValidator(z.object({
    name: z.string().min(1).max(100),
    bio: z.string().max(500).optional(),
  }))
  .handler(async ({ data, context }) => {
    // data is validated and typed
    await db.users.update(context.user.id, data)
    return { success: true }
  })
  

Zod is the natural choice because TanStack Start's type inference works seamlessly with Zod schemas.

User enumeration

Login and password reset endpoints should not reveal whether an email address is registered. If your login returns "User not found" for nonexistent emails but "Wrong password" for existing ones, an attacker can enumerate your user base.

  
// Vulnerable: leaks user existence
if (!user) return { error: 'User not found' }
if (!validPassword) return { error: 'Wrong password' }

// Secure: same message regardless
if (!user || !validPassword) {
  return { error: 'Invalid credentials' }
}
  

Also ensure that password verification takes the same amount of time whether the user exists or not. Use a dummy hash for nonexistent users so bcrypt always runs:

  
const DUMMY_HASH = await bcrypt.hash('dummy', 12) // computed once at startup

const hashToCheck = user?.passwordHash ?? DUMMY_HASH
const valid = await bcrypt.compare(data.password, hashToCheck)
  

XSS and React's protections

React escapes content in JSX by default, preventing most XSS attacks. Never use dangerouslySetInnerHTML with user-provided content. In a TanStack Start application, XSS is especially dangerous because it can read session state from client-side hooks, call server functions as the authenticated user, or exfiltrate tokens from memory.

Dependency supply chain

TanStack Start applications inherit the npm ecosystem's supply chain risks. Run npm audit in CI. Commit package-lock.json to version control. Audit new dependencies before adding them. Authentication libraries are especially sensitive because they handle secrets and tokens directly.

Build vs. buy: a realistic comparison

TanStack Start gives you strong primitives for building auth (server functions, middleware, typed context, sessions), but the authentication logic itself is yours to write. The session cookie is there; the rest is not.

Realistic time estimates for building authentication in TanStack Start:

  • MVP (session cookies with login/logout): 1 to 3 days.
  • Production-ready (with MFA, OAuth, account management): 4 to 8 weeks.
  • Enterprise-grade (SSO, SCIM, compliance): 3 to 6 months or more.
  • Ongoing maintenance: roughly 20 to 25% of the initial effort each year.

A managed provider compresses most of that into a few hours of integration work and shifts the security maintenance burden off your team. The trade-off is a dependency on an external service, so evaluate based on TanStack Start SDK quality, middleware compatibility, pricing at your expected scale, and whether the provider covers the enterprise features your customers will eventually require.

For most B2B SaaS teams, the question is not whether you can build authentication yourself. The question is whether it is the best use of your engineering time.

Production checklist

Security

  • Enforce auth on every server function that handles sensitive data or operations. Do not rely on route guards alone.
  • Use .inputValidator() with Zod on every server function that accepts input.
  • Hash passwords with bcrypt (async API, cost factor 12) or Argon2. Never store plain text passwords.
  • Use constant-time comparison for secrets and tokens.
  • Return generic error messages for login and password reset to prevent user enumeration.
  • Never use dangerouslySetInnerHTML with user-provided content.
  • Set httpOnly, secure, and sameSite on all session cookies.
  • Do not store tokens in localStorage or sessionStorage.
  • Keep all npm dependencies updated. Run npm audit in CI.
  • Commit package-lock.json to version control.
  • Implement rate limiting on login, registration, and password reset server functions.
  • Log authentication events (logins, failures, logouts) and monitor for anomalies.

Deployment

  • Generate a strong session secret (at least 32 characters) using openssl rand -base64 24 or a secrets manager.
  • Set secure: true on all cookies in production. Verify HTTPS is configured.
  • Keep session payloads small (under 4KB) when using vinxi's cookie-based sessions. For larger session data, use a server-side session store.
  • Test that unauthenticated direct calls to protected server functions return 401, not data.
  • Test authentication flows end to end: login, logout, session expiration, and protected route access.
  • Configure your reverse proxy or CDN to forward cookies correctly.
  • Set up monitoring for server function errors and latency spikes.

Conclusion

TanStack Start's authentication model is built around a simple principle: server functions are the security boundary, not routes. Once you internalize this, everything else follows. Middleware composes auth checks cleanly. beforeLoad handles the user experience. Typed context carries the authenticated user through your route tree without prop drilling.

If you are building authentication yourself, enforce auth on every server function. Use middleware to avoid repeating yourself. Use beforeLoad for route-level redirects. Validate all input with Zod. And test that direct calls to your server functions are rejected without a valid session.

If you are considering a managed provider, prioritize one with a dedicated TanStack Start SDK that integrates at the middleware and server function level, not one that was designed for a different framework and adapted as an afterthought.

Authentication is critical infrastructure. Choose the approach that matches where your application is headed, not just where it is today.

Sign up for WorkOS and secure your TanStack Start application.