Implement the authentication workflow in your SolidJS app, from sessions and sign-in to protected routes and authorization.
To compare libraries and hosted services, read Choose Authentication for SolidJS Apps.
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.
Protect the data/API boundary first. Any server function, server route, or other API endpoint that returns or mutates private data must authorize the request itself. beforeLoad is useful for route UX: it keeps users out of screens they cannot use and avoids triggering work that would fail anyway. It is not the security boundary for the data. See Authentication Server Primitives for the server-side pattern.
Server functions handle sensitive authentication logic securely on the server:
import { createServerFn } from '@tanstack/solid-start'
import { redirect } from '@tanstack/solid-router'
// Login server function
export const loginFn = createServerFn({ method: 'POST' })
.validator((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.get('userId')
if (!userId) {
return null
}
const user = await getUserById(userId)
return user ? { id: user.id, email: user.email, role: user.role } : null
},
)TanStack Start provides secure HTTP-only cookie sessions:
// utils/session.ts
import { useSession } from '@tanstack/solid-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,
},
})
}Load the current user in your root route's beforeLoad so the initial server render and child routes receive the same authentication state. useServerFn returns a callable function, not an object with data, isLoading, or refetch. You can call a server function directly from beforeLoad.
Merge this pattern into your existing root route, keeping its metadata and document shell:
// routes/__root.tsx
import {
createRootRoute,
HeadContent,
Outlet,
Scripts,
} from '@tanstack/solid-router'
import type { JSX } from 'solid-js'
import { getCurrentUserFn } from '../server/auth'
export const Route = createRootRoute({
headers: () => ({ 'Cache-Control': 'private, no-store' }),
beforeLoad: async () => ({ user: await getCurrentUserFn() }),
component: Outlet,
shellComponent: RootDocument,
})
function RootDocument({ children }: { children: JSX.Element }) {
return (
<html>
<head>
<HeadContent />
</head>
<body>
{children}
<Scripts />
</body>
</html>
)
}The root includes session-specific data, so its responses use Cache-Control: private, no-store. Keep that policy on child routes that render this data, and do not override it with public caching at your CDN.
Read that state from a descendant component:
// components/AuthStatus.tsx
import { Route } from '../routes/__root'
export function AuthStatus() {
const context = Route.useRouteContext()
return <span>{context().user?.email ?? 'Signed out'}</span>
}After login or logout changes the session, call await router.invalidate() to reload the current user and rerun route guards. Keep private-data authorization in the server function itself, even when a route already checks the user.
Protect routes using beforeLoad:
// routes/_authed.tsx - Layout route for protected pages
import { createFileRoute, redirect } from '@tanstack/solid-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/solid-router'
export const Route = createFileRoute('/_authed/dashboard')({
component: DashboardComponent,
})
function DashboardComponent() {
const context = Route.useRouteContext()
return (
<div>
<h1>Welcome, {context().user.email}!</h1>
{/* Dashboard content */}
</div>
)
}// server/auth.ts
import bcrypt from 'bcryptjs'
import { createServerFn } from '@tanstack/solid-start'
// User registration
export const registerFn = createServerFn({ method: 'POST' })
.validator((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' })
}
},
})// 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' })
.validator((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' })
.validator((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' })
.validator((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 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
}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' })
.validator((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-flow.test.tsx
import { render, screen, fireEvent, waitFor } from '@solidjs/testing-library'
import { RouterProvider, createMemoryHistory } from '@tanstack/solid-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()
})
})
})Call the function returned by useServerFn with { data }. Track pending state in the component, handle invalid credentials, and refresh route context after a successful login. This form uses the loginFn from the server-functions example above. It requires JavaScript, so the fields stay disabled until hydration; method="post" also prevents credentials appearing in a native GET submission.
// components/LoginForm.tsx
import { createSignal } from 'solid-js'
import { useHydrated, useRouter } from '@tanstack/solid-router'
import { useServerFn } from '@tanstack/solid-start'
import { loginFn } from '../server/auth'
export function LoginForm() {
const hydrated = useHydrated()
const [isLoading, setIsLoading] = createSignal(false)
const [error, setError] = createSignal('')
const login = useServerFn(loginFn)
const router = useRouter()
const handleSubmit = async (
event: SubmitEvent & { currentTarget: HTMLFormElement },
) => {
event.preventDefault()
const formData = new FormData(event.currentTarget)
const email = formData.get('email')
const password = formData.get('password')
if (typeof email !== 'string' || typeof password !== 'string') {
return
}
setIsLoading(true)
setError('')
try {
const result = await login({ data: { email, password } })
if (result?.error) {
setError(result.error)
return
}
await router.invalidate()
} catch {
setError('Login failed. Please try again.')
} finally {
setIsLoading(false)
}
}
return (
<form method="post" onSubmit={handleSubmit}>
<fieldset disabled={!hydrated() || isLoading()}>
<label>
Email
<input name="email" type="email" autocomplete="username" required />
</label>
<label>
Password
<input
name="password"
type="password"
autocomplete="current-password"
required
/>
</label>
<button type="submit" disabled={isLoading()}>
{isLoading() ? 'Logging in...' : 'Login'}
</button>
</fieldset>
<p role="alert">{error()}</p>
</form>
)
}export const loginFn = createServerFn({ method: 'POST' })
.validator(
(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 }
})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, see the How-to Guides or explore our working examples.