TanStack
Authentication & Data

Implement Authentication in SolidJS

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.

Authentication Approaches

You have several options for authentication in your TanStack Start application:

Hosted Solutions:

  1. Clerk - Complete authentication platform with UI components
  2. WorkOS - Enterprise-focused with SSO and compliance features
  3. Better Auth - Open-source TypeScript library
  4. Auth.js - Open-source library supporting 80+ OAuth providers

DIY Implementation Benefits:

  • Full Control: Complete customization over authentication flow
  • No Vendor Lock-in: Own your authentication logic and user data
  • Custom Requirements: Implement specific business logic or compliance needs
  • Cost Control: No per-user pricing or usage limits

Authentication involves many considerations including password security, session management, rate limiting, CSRF protection, and various attack vectors.

Core Concepts

Authentication vs Authorization

  • Authentication: Who is this user? (Login/logout)
  • Authorization: What can this user do? (Permissions/roles)

TanStack Start provides server functions, request and cookie utilities, and route protection. Session storage and authentication come from your application or a library you choose.

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.

Essential Building Blocks

1. Server Functions for Authentication

Server functions handle sensitive authentication logic securely on the server:

tsx
import { createServerFn } from '@tanstack/solid-start'
import { redirect } from '@tanstack/solid-router'
import { getAppSession } from '~/utils/session'

// 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 getAppSession()
    session.userId = user.id
    await session.save()

    // Redirect to protected area
    throw redirect({ to: '/dashboard' })
  })

// Logout server function
export const logoutFn = createServerFn({ method: 'POST' }).handler(async () => {
  const session = await getAppSession()
  session.destroy()
  throw redirect({ to: '/' })
})

// Get current user
export const getCurrentUserFn = createServerFn({ method: 'GET' }).handler(
  async () => {
    const session = await getAppSession()
    const userId = session.userId

    if (!userId) {
      return null
    }

    const user = await getUserById(userId)
    return user ? { id: user.id, email: user.email, role: user.role } : null
  },
)

2. Session Management

Start does not provide a session API. This guide uses iron-session for encrypted cookie sessions. Start's cookie helpers connect it to server functions, server routes, and request middleware.

For encrypted cookie sessions, install iron-session:

shell
pnpm add iron-session

This example uses iron-session 9's cookie adapter. Its Node runtime requires Node 22.13 or newer; check the library's runtime requirements for other deployment targets.

tsx
// utils/session.ts — import only from server code
import { getIronSession } from 'iron-session'
import { getCookie, setCookie } from '@tanstack/solid-start/server'

type SessionData = {
  userId: string
  oauthState: string
}

export function getAppSession(ttl = 7 * 24 * 60 * 60) {
  const password = process.env.SESSION_SECRET
  if (!password || password.length < 32) {
    throw new Error('SESSION_SECRET must be at least 32 characters')
  }

  return getIronSession<SessionData>(
    { read: getCookie, write: setCookie },
    {
      cookieName: 'app-session',
      password,
      ttl,
      cookieOptions: {
        secure: process.env.NODE_ENV === 'production',
        sameSite: 'lax',
        httpOnly: true,
        path: '/',
      },
    },
  )
}

Read fields directly from the returned session, change them, then await session.save() before returning or redirecting. Missing or expired sessions have no user ID. session.destroy() removes the browser cookie. Keep one session object for the operation; another call to getAppSession() reads the incoming request again. For middleware, pass that object through request context when downstream code needs the same mutations.

The getAppSession helper belongs to your application. Start does not cache sessions, choose expiration rules, or synchronize concurrent writes. Keep secrets and session objects in server code, and read request-specific configuration inside the active request.

3. Authentication Context

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:

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

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

4. Route Protection

Protect routes using beforeLoad:

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

Implementation Patterns

Basic Email/Password Authentication

tsx
// 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 getAppSession()
    session.userId = user.id
    await session.save()

    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
}

Role-Based Access Control (RBAC)

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

Social Authentication Integration

tsx
// 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 getAppSession()
    session.oauthState = state
    await session.save()

    // Generate OAuth URL
    const authUrl = generateOAuthUrl(provider, state)

    throw redirect({ href: authUrl })
  })

Password Reset Flow

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

Security Best Practices

1. Password Security

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

2. Session Security

Set the library's server-validated expiration as well as appropriate cookie attributes. In the iron-session example, ttl limits a sealed credential's lifetime from its latest save; saving again renews it. A numeric cookieOptions.maxAge controls browser retention. In iron-session 9, explicitly setting maxAge: undefined also disables seal expiry; omit the property to use the configured ttl. Use an application-validated timestamp or a server-side record if you also need an absolute lifetime that writes cannot extend.

Use httpOnly, sameSite, and path deliberately, and require secure: true on HTTPS deployments. These cookie attributes complement server-side authorization and CSRF protections. Keep only the minimum session data and load current account permissions from their authoritative store. Logout removes a stateless cookie from the current browser; it cannot revoke a copy without server-side state.

3. Rate Limiting

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

4. Input Validation

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

Testing Authentication

Unit Testing Server Functions

tsx
// __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')
  })
})

Integration Testing

tsx
// __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()
    })
  })
})

Common Patterns

Loading States

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.

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

Remember Me Functionality

tsx
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 ttl = data.rememberMe ? 30 * 24 * 60 * 60 : 8 * 60 * 60
    const session = await getAppSession(ttl)
    session.userId = user.id
    await session.save()

    return { success: true }
  })

Migration from Other Solutions

From Client-Side Auth

If you're migrating from client-side authentication (localStorage, context only):

  1. Move authentication logic to server functions
  2. Replace localStorage with server sessions
  3. Update route protection to use beforeLoad
  4. Add proper security headers and CSRF protection

From Other Frameworks

  • Next.js: Replace API routes with server functions, migrate NextAuth sessions
  • SvelteKit: Move form actions to server functions, update route protection

Production Considerations

When choosing your authentication approach, consider these factors:

Hosted vs DIY Comparison

Hosted Solutions (Clerk, WorkOS, Better Auth):

  • Pre-built security measures and regular updates
  • UI components and user management features
  • Compliance certifications and audit trails
  • Support and documentation
  • Per-user or subscription pricing

DIY Implementation:

  • Complete control over implementation and data
  • No ongoing subscription costs
  • Custom business logic and workflows
  • Responsibility for security updates and monitoring
  • Need to handle edge cases and attack vectors

Security Considerations

Authentication systems need to handle various security aspects:

  • Password hashing and timing attack prevention
  • Session management and fixation protection
  • CSRF and XSS protection
  • Rate limiting and brute force prevention
  • OAuth flow security
  • Compliance requirements (GDPR, CCPA, etc.)

Next Steps

When implementing authentication, consider:

  • Security Review: Review your implementation for security best practices
  • Performance: Add caching for user lookups and session validation
  • Monitoring: Add logging and monitoring for authentication events
  • Compliance: Ensure compliance with relevant regulations if storing personal data

For other authentication approaches, check the Authentication Overview. For specific integration help, see the How-to Guides or explore our working examples.