This guide covers authentication patterns and shows how to implement your own authentication system with TanStack Start.
📋 Before You Start: Check our Authentication Overview for all available options including partner solutions and hosted services.
You have several options for authentication in your TanStack Start application:
Hosted Solutions:
DIY Implementation Benefits:
Authentication involves many considerations including password security, session management, rate limiting, CSRF protection, and various attack vectors.
TanStack Start provides the tools for both through server functions, sessions, and route protection.
Server functions handle sensitive authentication logic securely on the server:
import { createServerFn } from '@tanstack/react-start' import { redirect } from '@tanstack/react-router' // Login server function export const loginFn = createServerFn({ method: 'POST' }) .inputValidator((data: { email: string; password: string }) => data) .handler(async ({ data }) => { // Verify credentials (replace with your auth logic) const user = await authenticateUser(data.email, data.password) if (!user) { return { error: 'Invalid credentials' } } // Create session const session = await useAppSession() await session.update({ userId: user.id, email: user.email, }) // Redirect to protected area throw redirect({ to: '/dashboard' }) }) // Logout server function export const logoutFn = createServerFn({ method: 'POST' }).handler(async () => { const session = await useAppSession() await session.clear() throw redirect({ to: '/' }) }) // Get current user export const getCurrentUserFn = createServerFn({ method: 'GET' }).handler( async () => { const session = await useAppSession() const userId = session.data.userId if (!userId) { return null } return await getUserById(userId) }, ) import { createServerFn } from '@tanstack/react-start' import { redirect } from '@tanstack/react-router' // Login server function export const loginFn = createServerFn({ method: 'POST' }) .inputValidator((data: { email: string; password: string }) => data) .handler(async ({ data }) => { // Verify credentials (replace with your auth logic) const user = await authenticateUser(data.email, data.password) if (!user) { return { error: 'Invalid credentials' } } // Create session const session = await useAppSession() await session.update({ userId: user.id, email: user.email, }) // Redirect to protected area throw redirect({ to: '/dashboard' }) }) // Logout server function export const logoutFn = createServerFn({ method: 'POST' }).handler(async () => { const session = await useAppSession() await session.clear() throw redirect({ to: '/' }) }) // Get current user export const getCurrentUserFn = createServerFn({ method: 'GET' }).handler( async () => { const session = await useAppSession() const userId = session.data.userId if (!userId) { return null } return await getUserById(userId) }, ) TanStack Start provides secure HTTP-only cookie sessions:
// utils/session.ts import { useSession } from '@tanstack/react-start/server' type SessionData = { userId?: string email?: string role?: string } export function useAppSession() { return useSession<SessionData>({ // Session configuration name: 'app-session', password: process.env.SESSION_SECRET!, // At least 32 characters // Optional: customize cookie settings cookie: { secure: process.env.NODE_ENV === 'production', sameSite: 'lax', httpOnly: true, }, }) } // utils/session.ts import { useSession } from '@tanstack/react-start/server' type SessionData = { userId?: string email?: string role?: string } export function useAppSession() { return useSession<SessionData>({ // Session configuration name: 'app-session', password: process.env.SESSION_SECRET!, // At least 32 characters // Optional: customize cookie settings cookie: { secure: process.env.NODE_ENV === 'production', sameSite: 'lax', httpOnly: true, }, }) } Share authentication state across your application:
// contexts/auth.tsx import { createContext, useContext, ReactNode } from 'react' import { useServerFn } from '@tanstack/react-start' import { getCurrentUserFn } from '../server/auth' type User = { id: string email: string role: string } type AuthContextType = { user: User | null isLoading: boolean refetch: () => void } const AuthContext = createContext<AuthContextType | undefined>(undefined) export function AuthProvider({ children }: { children: ReactNode }) { const { data: user, isLoading, refetch } = useServerFn(getCurrentUserFn) return ( <AuthContext.Provider value={{ user, isLoading, refetch }}> {children} </AuthContext.Provider> ) } export function useAuth() { const context = useContext(AuthContext) if (!context) { throw new Error('useAuth must be used within AuthProvider') } return context } // contexts/auth.tsx import { createContext, useContext, ReactNode } from 'react' import { useServerFn } from '@tanstack/react-start' import { getCurrentUserFn } from '../server/auth' type User = { id: string email: string role: string } type AuthContextType = { user: User | null isLoading: boolean refetch: () => void } const AuthContext = createContext<AuthContextType | undefined>(undefined) export function AuthProvider({ children }: { children: ReactNode }) { const { data: user, isLoading, refetch } = useServerFn(getCurrentUserFn) return ( <AuthContext.Provider value={{ user, isLoading, refetch }}> {children} </AuthContext.Provider> ) } export function useAuth() { const context = useContext(AuthContext) if (!context) { throw new Error('useAuth must be used within AuthProvider') } return context } Protect routes using beforeLoad:
// routes/_authed.tsx - Layout route for protected pages import { createFileRoute, redirect } from '@tanstack/react-router' import { getCurrentUserFn } from '../server/auth' export const Route = createFileRoute('/_authed')({ beforeLoad: async ({ location }) => { const user = await getCurrentUserFn() if (!user) { throw redirect({ to: '/login', search: { redirect: location.href }, }) } // Pass user to child routes return { user } }, }) // routes/_authed.tsx - Layout route for protected pages import { createFileRoute, redirect } from '@tanstack/react-router' import { getCurrentUserFn } from '../server/auth' export const Route = createFileRoute('/_authed')({ beforeLoad: async ({ location }) => { const user = await getCurrentUserFn() if (!user) { throw redirect({ to: '/login', search: { redirect: location.href }, }) } // Pass user to child routes return { user } }, }) // routes/_authed/dashboard.tsx - Protected route import { createFileRoute } from '@tanstack/react-router' export const Route = createFileRoute('/_authed/dashboard')({ component: DashboardComponent, }) function DashboardComponent() { const { user } = Route.useRouteContext() return ( <div> <h1>Welcome, {user.email}!</h1> {/* Dashboard content */} </div> ) } // routes/_authed/dashboard.tsx - Protected route import { createFileRoute } from '@tanstack/react-router' export const Route = createFileRoute('/_authed/dashboard')({ component: DashboardComponent, }) function DashboardComponent() { const { user } = Route.useRouteContext() return ( <div> <h1>Welcome, {user.email}!</h1> {/* Dashboard content */} </div> ) } // server/auth.ts import bcrypt from 'bcryptjs' import { createServerFn } from '@tanstack/react-start' // User registration export const registerFn = createServerFn({ method: 'POST' }) .inputValidator( (data: { email: string; password: string; name: string }) => data, ) .handler(async ({ data }) => { // Check if user exists const existingUser = await getUserByEmail(data.email) if (existingUser) { return { error: 'User already exists' } } // Hash password const hashedPassword = await bcrypt.hash(data.password, 12) // Create user const user = await createUser({ email: data.email, password: hashedPassword, name: data.name, }) // Create session const session = await useAppSession() await session.update({ userId: user.id }) return { success: true, user: { id: user.id, email: user.email } } }) async function authenticateUser(email: string, password: string) { const user = await getUserByEmail(email) if (!user) return null const isValid = await bcrypt.compare(password, user.password) return isValid ? user : null } // server/auth.ts import bcrypt from 'bcryptjs' import { createServerFn } from '@tanstack/react-start' // User registration export const registerFn = createServerFn({ method: 'POST' }) .inputValidator( (data: { email: string; password: string; name: string }) => data, ) .handler(async ({ data }) => { // Check if user exists const existingUser = await getUserByEmail(data.email) if (existingUser) { return { error: 'User already exists' } } // Hash password const hashedPassword = await bcrypt.hash(data.password, 12) // Create user const user = await createUser({ email: data.email, password: hashedPassword, name: data.name, }) // Create session const session = await useAppSession() await session.update({ userId: user.id }) return { success: true, user: { id: user.id, email: user.email } } }) async function authenticateUser(email: string, password: string) { const user = await getUserByEmail(email) if (!user) return null const isValid = await bcrypt.compare(password, user.password) return isValid ? user : null } // utils/auth.ts export const roles = { USER: 'user', ADMIN: 'admin', MODERATOR: 'moderator', } as const type Role = (typeof roles)[keyof typeof roles] export function hasPermission(userRole: Role, requiredRole: Role): boolean { const hierarchy = { [roles.USER]: 0, [roles.MODERATOR]: 1, [roles.ADMIN]: 2, } return hierarchy[userRole] >= hierarchy[requiredRole] } // Protected route with role check export const Route = createFileRoute('/_authed/admin/')({ beforeLoad: async ({ context }) => { if (!hasPermission(context.user.role, roles.ADMIN)) { throw redirect({ to: '/unauthorized' }) } }, }) // utils/auth.ts export const roles = { USER: 'user', ADMIN: 'admin', MODERATOR: 'moderator', } as const type Role = (typeof roles)[keyof typeof roles] export function hasPermission(userRole: Role, requiredRole: Role): boolean { const hierarchy = { [roles.USER]: 0, [roles.MODERATOR]: 1, [roles.ADMIN]: 2, } return hierarchy[userRole] >= hierarchy[requiredRole] } // Protected route with role check export const Route = createFileRoute('/_authed/admin/')({ beforeLoad: async ({ context }) => { if (!hasPermission(context.user.role, roles.ADMIN)) { throw redirect({ to: '/unauthorized' }) } }, }) // Example with OAuth providers export const authProviders = { google: { clientId: process.env.GOOGLE_CLIENT_ID!, redirectUri: `${process.env.APP_URL}/auth/google/callback`, }, github: { clientId: process.env.GITHUB_CLIENT_ID!, redirectUri: `${process.env.APP_URL}/auth/github/callback`, }, } export const initiateOAuthFn = createServerFn({ method: 'POST' }) .inputValidator((data: { provider: 'google' | 'github' }) => data) .handler(async ({ data }) => { const provider = authProviders[data.provider] const state = generateRandomState() // Store state in session for CSRF protection const session = await useAppSession() await session.update({ oauthState: state }) // Generate OAuth URL const authUrl = generateOAuthUrl(provider, state) throw redirect({ href: authUrl }) }) // Example with OAuth providers export const authProviders = { google: { clientId: process.env.GOOGLE_CLIENT_ID!, redirectUri: `${process.env.APP_URL}/auth/google/callback`, }, github: { clientId: process.env.GITHUB_CLIENT_ID!, redirectUri: `${process.env.APP_URL}/auth/github/callback`, }, } export const initiateOAuthFn = createServerFn({ method: 'POST' }) .inputValidator((data: { provider: 'google' | 'github' }) => data) .handler(async ({ data }) => { const provider = authProviders[data.provider] const state = generateRandomState() // Store state in session for CSRF protection const session = await useAppSession() await session.update({ oauthState: state }) // Generate OAuth URL const authUrl = generateOAuthUrl(provider, state) throw redirect({ href: authUrl }) }) // Password reset request export const requestPasswordResetFn = createServerFn({ method: 'POST' }) .inputValidator((data: { email: string }) => data) .handler(async ({ data }) => { const user = await getUserByEmail(data.email) if (!user) { // Don't reveal if email exists return { success: true } } const token = generateSecureToken() const expires = new Date(Date.now() + 60 * 60 * 1000) // 1 hour await savePasswordResetToken(user.id, token, expires) await sendPasswordResetEmail(user.email, token) return { success: true } }) // Password reset confirmation export const resetPasswordFn = createServerFn({ method: 'POST' }) .inputValidator((data: { token: string; newPassword: string }) => data) .handler(async ({ data }) => { const resetToken = await getPasswordResetToken(data.token) if (!resetToken || resetToken.expires < new Date()) { return { error: 'Invalid or expired token' } } const hashedPassword = await bcrypt.hash(data.newPassword, 12) await updateUserPassword(resetToken.userId, hashedPassword) await deletePasswordResetToken(data.token) return { success: true } }) // Password reset request export const requestPasswordResetFn = createServerFn({ method: 'POST' }) .inputValidator((data: { email: string }) => data) .handler(async ({ data }) => { const user = await getUserByEmail(data.email) if (!user) { // Don't reveal if email exists return { success: true } } const token = generateSecureToken() const expires = new Date(Date.now() + 60 * 60 * 1000) // 1 hour await savePasswordResetToken(user.id, token, expires) await sendPasswordResetEmail(user.email, token) return { success: true } }) // Password reset confirmation export const resetPasswordFn = createServerFn({ method: 'POST' }) .inputValidator((data: { token: string; newPassword: string }) => data) .handler(async ({ data }) => { const resetToken = await getPasswordResetToken(data.token) if (!resetToken || resetToken.expires < new Date()) { return { error: 'Invalid or expired token' } } const hashedPassword = await bcrypt.hash(data.newPassword, 12) await updateUserPassword(resetToken.userId, hashedPassword) await deletePasswordResetToken(data.token) return { success: true } }) // Use strong hashing (bcrypt, scrypt, or argon2) import bcrypt from 'bcryptjs' const saltRounds = 12 // Adjust based on your security needs const hashedPassword = await bcrypt.hash(password, saltRounds) // Use strong hashing (bcrypt, scrypt, or argon2) import bcrypt from 'bcryptjs' const saltRounds = 12 // Adjust based on your security needs const hashedPassword = await bcrypt.hash(password, saltRounds) // Use secure session configuration export function useAppSession() { return useSession({ name: 'app-session', password: process.env.SESSION_SECRET!, // 32+ characters cookie: { secure: process.env.NODE_ENV === 'production', // HTTPS only in production sameSite: 'lax', // CSRF protection httpOnly: true, // XSS protection maxAge: 7 * 24 * 60 * 60, // 7 days }, }) } // Use secure session configuration export function useAppSession() { return useSession({ name: 'app-session', password: process.env.SESSION_SECRET!, // 32+ characters cookie: { secure: process.env.NODE_ENV === 'production', // HTTPS only in production sameSite: 'lax', // CSRF protection httpOnly: true, // XSS protection maxAge: 7 * 24 * 60 * 60, // 7 days }, }) } // Simple in-memory rate limiting (use Redis in production) const loginAttempts = new Map<string, { count: number; resetTime: number }>() export const rateLimitLogin = (ip: string): boolean => { const now = Date.now() const attempts = loginAttempts.get(ip) if (!attempts || now > attempts.resetTime) { loginAttempts.set(ip, { count: 1, resetTime: now + 15 * 60 * 1000 }) // 15 min return true } if (attempts.count >= 5) { return false // Too many attempts } attempts.count++ return true } // Simple in-memory rate limiting (use Redis in production) const loginAttempts = new Map<string, { count: number; resetTime: number }>() export const rateLimitLogin = (ip: string): boolean => { const now = Date.now() const attempts = loginAttempts.get(ip) if (!attempts || now > attempts.resetTime) { loginAttempts.set(ip, { count: 1, resetTime: now + 15 * 60 * 1000 }) // 15 min return true } if (attempts.count >= 5) { return false // Too many attempts } attempts.count++ return true } import { z } from 'zod' const loginSchema = z.object({ email: z.string().email().max(255), password: z.string().min(8).max(100), }) export const loginFn = createServerFn({ method: 'POST' }) .inputValidator((data) => loginSchema.parse(data)) .handler(async ({ data }) => { // data is now validated }) import { z } from 'zod' const loginSchema = z.object({ email: z.string().email().max(255), password: z.string().min(8).max(100), }) export const loginFn = createServerFn({ method: 'POST' }) .inputValidator((data) => loginSchema.parse(data)) .handler(async ({ data }) => { // data is now validated }) // __tests__/auth.test.ts import { describe, it, expect, beforeEach } from 'vitest' import { loginFn } from '../server/auth' describe('Authentication', () => { beforeEach(async () => { await setupTestDatabase() }) it('should login with valid credentials', async () => { const result = await loginFn({ data: { email: 'test@example.com', password: 'password123' }, }) expect(result.error).toBeUndefined() expect(result.user).toBeDefined() }) it('should reject invalid credentials', async () => { const result = await loginFn({ data: { email: 'test@example.com', password: 'wrongpassword' }, }) expect(result.error).toBe('Invalid credentials') }) }) // __tests__/auth.test.ts import { describe, it, expect, beforeEach } from 'vitest' import { loginFn } from '../server/auth' describe('Authentication', () => { beforeEach(async () => { await setupTestDatabase() }) it('should login with valid credentials', async () => { const result = await loginFn({ data: { email: 'test@example.com', password: 'password123' }, }) expect(result.error).toBeUndefined() expect(result.user).toBeDefined() }) it('should reject invalid credentials', async () => { const result = await loginFn({ data: { email: 'test@example.com', password: 'wrongpassword' }, }) expect(result.error).toBe('Invalid credentials') }) }) // __tests__/auth-flow.test.tsx import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { RouterProvider, createMemoryHistory } from '@tanstack/react-router' import { router } from '../router' describe('Authentication Flow', () => { it('should redirect to login when accessing protected route', async () => { const history = createMemoryHistory() history.push('/dashboard') // Protected route render(<RouterProvider router={router} history={history} />) await waitFor(() => { expect(screen.getByText('Login')).toBeInTheDocument() }) }) }) // __tests__/auth-flow.test.tsx import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { RouterProvider, createMemoryHistory } from '@tanstack/react-router' import { router } from '../router' describe('Authentication Flow', () => { it('should redirect to login when accessing protected route', async () => { const history = createMemoryHistory() history.push('/dashboard') // Protected route render(<RouterProvider router={router} history={history} />) await waitFor(() => { expect(screen.getByText('Login')).toBeInTheDocument() }) }) }) function LoginForm() { const [isLoading, setIsLoading] = useState(false) const loginMutation = useServerFn(loginFn) const handleSubmit = async (data: LoginData) => { setIsLoading(true) try { await loginMutation.mutate(data) } catch (error) { // Handle error } finally { setIsLoading(false) } } return ( <form onSubmit={handleSubmit}> {/* Form fields */} <button disabled={isLoading}> {isLoading ? 'Logging in...' : 'Login'} </button> </form> ) } function LoginForm() { const [isLoading, setIsLoading] = useState(false) const loginMutation = useServerFn(loginFn) const handleSubmit = async (data: LoginData) => { setIsLoading(true) try { await loginMutation.mutate(data) } catch (error) { // Handle error } finally { setIsLoading(false) } } return ( <form onSubmit={handleSubmit}> {/* Form fields */} <button disabled={isLoading}> {isLoading ? 'Logging in...' : 'Login'} </button> </form> ) } export const loginFn = createServerFn({ method: 'POST' }) .inputValidator( (data: { email: string; password: string; rememberMe?: boolean }) => data, ) .handler(async ({ data }) => { const user = await authenticateUser(data.email, data.password) if (!user) return { error: 'Invalid credentials' } const session = await useAppSession() await session.update( { userId: user.id }, { // Extend session if remember me is checked maxAge: data.rememberMe ? 30 * 24 * 60 * 60 : undefined, // 30 days vs session }, ) return { success: true } }) export const loginFn = createServerFn({ method: 'POST' }) .inputValidator( (data: { email: string; password: string; rememberMe?: boolean }) => data, ) .handler(async ({ data }) => { const user = await authenticateUser(data.email, data.password) if (!user) return { error: 'Invalid credentials' } const session = await useAppSession() await session.update( { userId: user.id }, { // Extend session if remember me is checked maxAge: data.rememberMe ? 30 * 24 * 60 * 60 : undefined, // 30 days vs session }, ) return { success: true } }) Study these implementations to understand different authentication patterns:
If you're migrating from client-side authentication (localStorage, context only):
When choosing your authentication approach, consider these factors:
Hosted Solutions (Clerk, WorkOS, Better Auth):
DIY Implementation:
Authentication systems need to handle various security aspects:
When implementing authentication, consider:
For other authentication approaches, check the Authentication Overview. For specific integration help, explore our working examples.
