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
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
| Aspect | Traditional web app | SPA |
|---|---|---|
| Session management | Server side | Client side |
| Page navigation | Auth check on the server | Auth check on the client |
| Token storage | Cookie (auto-sent) | Explicit management needed |
| CSRF protection | Required | Not needed for header-sent parts (needed when using Cookies together) |
| XSS protection | Important | Extremely 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.
Recommended: JWT + HttpOnly Cookie
In this book, we adopt the following method that balances security and practicality.
| Token | Storage location | Expiration | Use |
|---|---|---|---|
| Access token | Memory (state management) | 15 minutes | API authentication |
| Refresh token | HttpOnly Cookie | 7 days | Token refresh |
Token storage locations and security
localStorage / sessionStorage
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.
HttpOnly Cookie
// ✅ 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 })
}))
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.
| Term | Role |
|---|---|
| OAuth 2.0 | The standard for "Authorization." A mechanism for delegating access rights to specific resources |
| OAuth 2.1 | The 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 Flow | A 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 |
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.
-
Implement the authentication store: using Zustand, create an authentication store that manages
user,accessToken,isAuthenticated, andisLoading. Also implement thesetAuthandclearAuthactions. -
Implement PrivateRoute: create a
PrivateRoutecomponent that redirects unauthenticated users to/login. Display an appropriate UI while loading. -
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
- Create the store with
create<AuthState>((set) => ({ ... })).setAuthreceivesuserandaccessTokenand setsisAuthenticated: true. - Get
isAuthenticatedandisLoadingfromuseAuthStore, and return<Navigate to="/login" />or children based on the condition. - Call
setAuthinuseMutation'sonSuccess, and set the error message inonError. Also disable the button withisPending.
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.