Skip to main content

Designing SPA authentication

In this chapter, you learn about designing and implementing authentication in a single-page application (SPA).

What you learn in this chapter

  • The challenges of SPA authentication and how to address them
  • How JWT authentication (access token + refresh token) works
  • Secure token storage (memory + HttpOnly Cookie)
  • Automatic token management with Axios interceptors
info

In this chapter, you learn authentication design and implementation patterns. The frontend code can be implemented based on the Chapter 3 project. The backend is built in the next chapter (Chapter 28).

The challenges of SPA authentication

Unlike traditional server-side rendering, an SPA has its own authentication challenges.

Traditional web application vs SPA

AspectTraditional web appSPA
Session managementServer sideClient side
Page navigationAuth check on the serverAuth check on the client
Token storageCookie (auto-sent)Explicit management needed
CSRF protectionRequiredNot needed for header-sent parts (needed when using Cookies together)
XSS protectionImportantExtremely important

Comparison of authentication methods

1. Session authentication

┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Client │ ───> │ Server │ ───> │ Session │
│ (Cookie) │ <─── │ (Set-Cookie)│ <─── │ Store │
└─────────────┘ └─────────────┘ └─────────────┘

Pros: can be invalidated on the server side, simple implementation Cons: holds state on the server, hard to scale.

2. JWT authentication (access token + refresh token)

┌─────────────┐ ┌─────────────┐
│ Client │ ───> │ Server │
│ │ │ │
│ Access Token│ │ Verify JWT │
│ (Memory/ │ <─── │ │
│ Storage) │ │ Issue JWT │
│ │ │ │
│ Refresh │ │ Verify │
│ Token │ ───> │ Refresh │
│ (HttpOnly) │ <─── │ Token │
└─────────────┘ └─────────────┘

Pros: stateless, scalable, microservices-friendly Cons: complex implementation, token invalidation is hard.

In this book, we adopt the following method that balances security and practicality.

TokenStorage locationExpirationUse
Access tokenMemory (state management)15 minutesAPI authentication
Refresh tokenHttpOnly Cookie7 daysToken refresh

Token storage locations and security

localStorage / sessionStorage

warning

Saving an access token to localStorage or sessionStorage has security risks. Because it can be accessed from JavaScript via an XSS attack, it is not recommended for production.

// ❌ Not recommended: XSS vulnerability
localStorage.setItem('accessToken', token)

Risk: accessible from JavaScript via an XSS attack.

// ✅ Recommended: not accessible from JavaScript
// Set on the server side
res.cookie('refreshToken', token, {
httpOnly: true,
secure: true, // HTTPS required
sameSite: 'strict', // CSRF protection
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
})

Memory (React State / Zustand)

// ✅ Recommended: keep the access token in memory
// It must be re-fetched on reload
const useAuthStore = create<AuthState>((set) => ({
accessToken: null,
setAccessToken: (token) => set({ accessToken: token })
}))
warning

Keeping the access token in memory does not completely prevent XSS attacks. Once you allow script execution via XSS, the attacker can call fetch directly in the victim's browser. If the Cookie has HttpOnly, the Cookie itself cannot be read, but you cannot stop the attacker from "hitting the API in the browser's name." In other words, memory storage is positioned as having the effect of preventing Cookie theft, but not neutralizing XSS damage. As a fundamental countermeasure, a Content Security Policy (CSP) and a design that does not render untrusted HTML are essential.

The basics of OAuth 2.1 / OIDC / PKCE

In this book, we cover a setup where we issue and verify JWTs ourselves, but when delegating authentication to an external auth provider (Auth0, Okta, Google, GitHub, etc.), it is common to conform to OAuth 2.0 / 2.1 and OpenID Connect (OIDC). Let's organize the minimum terminology.

TermRole
OAuth 2.0The standard for "Authorization." A mechanism for delegating access rights to specific resources
OAuth 2.1The successor specification that incorporates the best practices of OAuth 2.0. In effect, PKCE became required for SPAs
OpenID Connect (OIDC)A standard that adds "Authentication" on top of OAuth 2.0. You can get user information with an ID Token (JWT)
Authorization Code FlowA flow that redirects the user to the auth provider and exchanges the returned authorization code for a token
PKCE (Proof Key for Code Exchange)An additional verification to prevent authorization code interception attacks. Required for SPAs
info

Because an SPA cannot securely store a client secret, the current industry standard is "Authorization Code Flow with PKCE." The Implicit Flow (the old method of receiving the access token directly in the URL fragment) has been deprecated, and you must not use it.

OAuth / OIDC libraries (oidc-client-ts, @auth0/auth0-react, etc.) handle the details of PKCE for you, so you usually leave it to the library. Read this book's own implementation as a learning exercise, and in production make an auth provider your first choice.

Designing the authentication flow

The login flow

1. User enters credentials


2. POST /api/auth/login


3. Server validates credentials


4. Server returns:
- Access Token (response body)
- Refresh Token (HttpOnly Cookie)


5. Client stores Access Token in memory

The token refresh flow

1. API request with expired Access Token


2. Server returns 401 Unauthorized


3. Client calls POST /api/auth/refresh
(Refresh Token auto-sent via Cookie)


4. Server validates Refresh Token


5. Server returns new Access Token


6. Client retries original request

Implementation: the authentication store

Managing the auth state with Zustand

// stores/authStore.ts
import { create } from 'zustand'

type User = {
id: string
email: string
name: string
}

type AuthState = {
user: User | null
accessToken: string | null
isAuthenticated: boolean
isLoading: boolean
setAuth: (user: User, accessToken: string) => void
clearAuth: () => void
setLoading: (loading: boolean) => void
}

export const useAuthStore = create<AuthState>((set) => ({
user: null,
accessToken: null,
isAuthenticated: false,
isLoading: true,

setAuth: (user, accessToken) =>
set({
user,
accessToken,
isAuthenticated: true,
isLoading: false
}),

clearAuth: () =>
set({
user: null,
accessToken: null,
isAuthenticated: false,
isLoading: false
}),

setLoading: (isLoading) => set({ isLoading })
}))

Implementation: the API client

We use axios as the HTTP client. First, let's install it.

npm install axios

Token management with Axios interceptors

// lib/api.ts
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'
import { useAuthStore } from '../stores/authStore'

const api = axios.create({
baseURL: import.meta.env.VITE_API_URL,
withCredentials: true // enable sending Cookies
})

// Request interceptor: attach the access token
api.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const { accessToken } = useAuthStore.getState()

if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`
}

return config
})

// Response interceptor: refresh the token on 401
let isRefreshing = false
let failedQueue: Array<{
resolve: (token: string) => void
reject: (error: unknown) => void
}> = []

const processQueue = (error: unknown, token: string | null = null) => {
failedQueue.forEach((promise) => {
if (error) {
promise.reject(error)
} else {
promise.resolve(token!)
}
})
failedQueue = []
}

api.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & {
_retry?: boolean
}

// A 401 error and before a retry (exclude the 401 of /auth/refresh itself)
if (
error.response?.status === 401 &&
!originalRequest._retry &&
!originalRequest.url?.includes('/auth/refresh')
) {
if (isRefreshing) {
// Wait while refreshing
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject })
}).then((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`
return api(originalRequest)
})
}

originalRequest._retry = true
isRefreshing = true

try {
const { data } = await axios.post(
`${import.meta.env.VITE_API_URL}/auth/refresh`,
{},
{ withCredentials: true }
)

const { accessToken, user } = data
useAuthStore.getState().setAuth(user, accessToken)

processQueue(null, accessToken)

originalRequest.headers.Authorization = `Bearer ${accessToken}`
return api(originalRequest)
} catch (refreshError) {
processQueue(refreshError, null)
useAuthStore.getState().clearAuth()
// Reached here only when an API request from a protected page fails
// (the initial auth check of a public page is left to the AuthProvider's catch)
window.location.href = '/login'
return Promise.reject(refreshError)
} finally {
isRefreshing = false
}
}

return Promise.reject(error)
}
)

export default api

Implementation: the authentication hooks

Login and logout

// hooks/useAuth.ts
import { useEffect } from 'react'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router'
import api from '../lib/api'
import { useAuthStore } from '../stores/authStore'

type LoginCredentials = {
email: string
password: string
}

type RegisterData = {
name: string
email: string
password: string
}

export function useLogin() {
const { setAuth } = useAuthStore()
const navigate = useNavigate()

return useMutation({
mutationFn: async (credentials: LoginCredentials) => {
const { data } = await api.post('/auth/login', credentials)
return data
},
onSuccess: (data) => {
setAuth(data.user, data.accessToken)
navigate('/dashboard')
}
})
}

export function useRegister() {
const { setAuth } = useAuthStore()
const navigate = useNavigate()

return useMutation({
mutationFn: async (userData: RegisterData) => {
const { data } = await api.post('/auth/register', userData)
return data
},
onSuccess: (data) => {
setAuth(data.user, data.accessToken)
navigate('/dashboard')
}
})
}

export function useLogout() {
const { clearAuth } = useAuthStore()
const navigate = useNavigate()

return useMutation({
mutationFn: async () => {
await api.post('/auth/logout')
},
onSuccess: () => {
clearAuth()
navigate('/login')
}
})
}

// Confirm the initial auth state (on reload)
export function useAuthCheck() {
const { setAuth, clearAuth } = useAuthStore()

const query = useQuery({
queryKey: ['auth', 'me'],
queryFn: async () => {
const { data } = await api.get('/auth/me')
return data
},
retry: false,
staleTime: Infinity,
gcTime: Infinity
})

// In v5, useQuery's onSuccess / onError were removed, so handle side effects with useEffect
useEffect(() => {
if (query.data) {
// The /auth/me response does not include accessToken, so keep the existing token
// (overwriting with undefined causes an extra refresh round-trip on every mount)
const { accessToken } = useAuthStore.getState()
if (accessToken) {
setAuth(query.data.user, accessToken)
}
}
}, [query.data, setAuth])

useEffect(() => {
if (query.isError) {
clearAuth()
}
}, [query.isError, clearAuth])

return query
}

Implementation: protected routes

The PrivateRoute component

// components/PrivateRoute.tsx
import { Navigate, useLocation } from 'react-router'
import { useAuthStore } from '../stores/authStore'

type PrivateRouteProps = {
children: React.ReactNode
}

export function PrivateRoute({ children }: PrivateRouteProps) {
const { isAuthenticated, isLoading } = useAuthStore()
const location = useLocation()

if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
</div>
)
}

if (!isAuthenticated) {
// Allow returning to the original page after login
return <Navigate to="/login" state={{ from: location }} replace />
}

return <>{children}</>
}

Routing configuration

// App.tsx
import { Routes, Route } from 'react-router'
import { PrivateRoute } from './components/PrivateRoute'
import { AuthProvider } from './components/AuthProvider'
import { LoginPage } from './pages/LoginPage'
import { RegisterPage } from './pages/RegisterPage'
import { DashboardPage } from './pages/DashboardPage'
import { ProjectsPage } from './pages/ProjectsPage'

function App() {
return (
<AuthProvider>
<Routes>
{/* Public routes */}
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />

{/* Protected routes */}
<Route
path="/dashboard"
element={
<PrivateRoute>
<DashboardPage />
</PrivateRoute>
}
/>
<Route
path="/projects/*"
element={
<PrivateRoute>
<ProjectsPage />
</PrivateRoute>
}
/>
</Routes>
</AuthProvider>
)
}

AuthProvider (initial auth check)

// components/AuthProvider.tsx
import { useEffect, ReactNode } from 'react'
import { useAuthStore } from '../stores/authStore'
import api from '../lib/api'

type AuthProviderProps = {
children: ReactNode
}

export function AuthProvider({ children }: AuthProviderProps) {
const { setAuth, clearAuth } = useAuthStore()

useEffect(() => {
const checkAuth = async () => {
try {
// Restore the auth state with the refresh token
const { data } = await api.post('/auth/refresh')
setAuth(data.user, data.accessToken)
} catch {
clearAuth()
}
}

checkAuth()
}, [setAuth, clearAuth])

return <>{children}</>
}

Implementation: the login form

// pages/LoginPage.tsx
import { useState } from 'react'
import { Link, useLocation, Navigate } from 'react-router'
import { useLogin } from '../hooks/useAuth'
import { useAuthStore } from '../stores/authStore'

export function LoginPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const { mutate: login, isPending, error } = useLogin()
const { isAuthenticated } = useAuthStore()
const location = useLocation()

// If already logged in, redirect
if (isAuthenticated) {
const from = location.state?.from?.pathname || '/dashboard'
return <Navigate to={from} replace />
}

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
login({ email, password })
}

return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full space-y-8 p-8 bg-white rounded-lg shadow">
<h2 className="text-3xl font-bold text-center">Log in</h2>

<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="p-3 bg-red-100 text-red-700 rounded">
Incorrect email or password
</div>
)}

<div>
<label htmlFor="email" className="block text-sm font-medium">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md"
/>
</div>

<div>
<label htmlFor="password" className="block text-sm font-medium">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md"
/>
</div>

<button
type="submit"
disabled={isPending}
className="w-full py-2 px-4 bg-blue-500 text-white rounded-md
hover:bg-blue-600 disabled:opacity-50"
>
{isPending ? 'Logging in...' : 'Log in'}
</button>
</form>

<p className="text-center text-sm text-gray-600">
Don't have an account?
<Link to="/register" className="text-blue-500 hover:underline ml-1">
Sign up
</Link>
</p>
</div>
</div>
)
}

Security best practices

1. HTTPS required

// Force HTTPS in production
if (import.meta.env.PROD && location.protocol !== 'https:') {
location.replace(`https:${location.href.substring(location.protocol.length)}`)
}

2. CSRF token (when using Cookies)

// Send the CSRF token in the API client
api.interceptors.request.use((config) => {
const csrfToken = document.cookie
.split('; ')
.find(row => row.startsWith('XSRF-TOKEN='))
?.split('=')[1]

if (csrfToken) {
config.headers['X-XSRF-TOKEN'] = csrfToken
}

return config
})

3. XSS protection

// Sanitize user input
import DOMPurify from 'dompurify'

function sanitizeInput(input: string): string {
return DOMPurify.sanitize(input)
}

// Avoid using dangerouslySetInnerHTML
// If you use it, always sanitize
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(content) }} />

4. Password requirements

// A validation example
const passwordSchema = z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Include at least one uppercase letter')
.regex(/[a-z]/, 'Include at least one lowercase letter')
.regex(/[0-9]/, 'Include at least one number')

Try it

Take on the following challenges to deepen your understanding of SPA authentication.

  1. Implement the authentication store: using Zustand, create an authentication store that manages user, accessToken, isAuthenticated, and isLoading. Also implement the setAuth and clearAuth actions.

  2. Implement PrivateRoute: create a PrivateRoute component that redirects unauthenticated users to /login. Display an appropriate UI while loading.

  3. Implement the login form: create a form for email and password, and implement the login process with TanStack Query's useMutation. Also add a message display on error.

Hint
  1. Create the store with create<AuthState>((set) => ({ ... })). setAuth receives user and accessToken and sets isAuthenticated: true.
  2. Get isAuthenticated and isLoading from useAuthStore, and return <Navigate to="/login" /> or children based on the condition.
  3. Call setAuth in useMutation's onSuccess, and set the error message in onError. Also disable the button with isPending.
Answer and explanation

Challenge 1: Implement the authentication store

// stores/authStore.ts
import { create } from 'zustand'

type User = {
id: string
email: string
name: string
}

type AuthState = {
user: User | null
accessToken: string | null
isAuthenticated: boolean
isLoading: boolean
setAuth: (user: User, accessToken: string) => void
clearAuth: () => void
setLoading: (loading: boolean) => void
}

export const useAuthStore = create<AuthState>((set) => ({
user: null,
accessToken: null,
isAuthenticated: false,
isLoading: true,

setAuth: (user, accessToken) =>
set({
user,
accessToken,
isAuthenticated: true,
isLoading: false
}),

clearAuth: () =>
set({
user: null,
accessToken: null,
isAuthenticated: false,
isLoading: false
}),

setLoading: (isLoading) => set({ isLoading })
}))

In Zustand, you can create a type-safe store by passing a type parameter to the create function. Call setAuth on login success, and call clearAuth on logout or an auth error. isLoading is true during the initial auth check.


Challenge 2: Implement PrivateRoute

// components/PrivateRoute.tsx
import { Navigate, useLocation } from 'react-router'
import { useAuthStore } from '../stores/authStore'

type PrivateRouteProps = {
children: React.ReactNode
}

export function PrivateRoute({ children }: PrivateRouteProps) {
const { isAuthenticated, isLoading } = useAuthStore()
const location = useLocation()

// Show a loading display during the auth check
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500" />
</div>
)
}

// If unauthenticated, redirect to the login page
if (!isAuthenticated) {
// Save the current path so you can return to the original page after login
return <Navigate to="/login" state={{ from: location }} replace />
}

// If authenticated, display the child components
return <>{children}</>
}
// Usage in App.tsx
import { Routes, Route } from 'react-router'
import { PrivateRoute } from './components/PrivateRoute'

function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/dashboard"
element={
<PrivateRoute>
<DashboardPage />
</PrivateRoute>
}
/>
</Routes>
)
}

It is important not to redirect during isLoading. If you redirect on page reload before the auth check completes, even authenticated users get bounced to the login page. By saving the original path in location.state, you can return to the original page after login.


Challenge 3: Implement the login form

// pages/LoginPage.tsx
import { useState } from 'react'
import { Link, useLocation, Navigate, useNavigate } from 'react-router'
import { useMutation } from '@tanstack/react-query'
import { useAuthStore } from '../stores/authStore'
import api from '../lib/api'

type LoginCredentials = {
email: string
password: string
}

export function LoginPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const { isAuthenticated, setAuth } = useAuthStore()
const location = useLocation()
const navigate = useNavigate()

const loginMutation = useMutation({
mutationFn: async (credentials: LoginCredentials) => {
const { data } = await api.post('/auth/login', credentials)
return data
},
onSuccess: (data) => {
setAuth(data.user, data.accessToken)
const from = location.state?.from?.pathname || '/dashboard'
navigate(from, { replace: true })
}
})

// If already logged in, go to the dashboard
if (isAuthenticated) {
return <Navigate to="/dashboard" replace />
}

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
loginMutation.mutate({ email, password })
}

return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full space-y-8 p-8 bg-white rounded-lg shadow">
<h2 className="text-3xl font-bold text-center">Log in</h2>

<form onSubmit={handleSubmit} className="space-y-6">
{loginMutation.isError && (
<div className="p-3 bg-red-100 text-red-700 rounded">
Incorrect email or password
</div>
)}

<div>
<label htmlFor="email" className="block text-sm font-medium">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="mt-1 block w-full px-3 py-2 border rounded-md"
/>
</div>

<div>
<label htmlFor="password" className="block text-sm font-medium">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="mt-1 block w-full px-3 py-2 border rounded-md"
/>
</div>

<button
type="submit"
disabled={loginMutation.isPending}
className="w-full py-2 px-4 bg-blue-500 text-white rounded-md
hover:bg-blue-600 disabled:opacity-50"
>
{loginMutation.isPending ? 'Logging in...' : 'Log in'}
</button>
</form>

<p className="text-center text-sm text-gray-600">
Don't have an account?
<Link to="/register" className="text-blue-500 hover:underline ml-1">
Sign up
</Link>
</p>
</div>
</div>
)
}

Using useMutation, you can concisely handle the loading state (isPending), the error state (isError), and the success callback (onSuccess). Call setAuth in onSuccess to update the auth state, and redirect to the saved original path.

Summary

  • In an SPA, be careful about where you store the auth token (XSS protection)
  • The access token in memory, the refresh token in an HttpOnly Cookie
  • Automatic token refresh with Axios interceptors
  • Auth checks with protected routes
  • Do not forget HTTPS, CSRF protection, and XSS protection

In the next chapter, you will learn how to build a backend API using NestJS and Prisma.