- Published on
React SPA Authentication Practical Guide — Cookie vs Storage, Axios Interceptors, XSS/CSRF Defense Complete Mastery
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- Overview — The Unique Nature of SPA Authentication
- Browser Storage Accessibility Table
- Authentication State Management Patterns
- Token Storage Strategy Implementations
- Axios Interceptor Setup
- Login/Logout Flow
- JWT Claims Parsing (Frontend)
- Protected Route Implementation
- XSS Defense
- CSRF Defense
- CORS Configuration Considerations
- Silent Refresh / Token Rotation
- Checklist
- Common Bugs and Misconceptions
- 1. "You should never put JWT in localStorage" — Really?
- 2. Checking Token Expiration Only on the Frontend
- 3. Cookies Not Being Sent Despite Setting withCredentials
- 4. Confusing XSS and CSRF
- 5. Sending Refresh Token with Every Request Like Access Token
- 6. Only Clearing Client State on Logout
- 7. Missing isLoading Handling During Initial Load
- References
SSO Cookie/JWT Authentication Series · Django Edition · Current: React Edition · Next.js Edition
Overview — The Unique Nature of SPA Authentication
In traditional Multi-Page Applications (MPA), the server manages sessions and maintains authentication state naturally by rendering HTML on every request. The server-side template engine checks login status, and unauthenticated users are simply redirected to the login page.
Single-Page Applications (SPA) like React are fundamentally different. JavaScript in the browser handles all screen transitions, and communication with the server happens only through API calls. Authentication in this architecture faces the following unique challenges:
- Dual state: Server session/token validity and client UI state are separated, causing synchronization issues
- Page refresh: When an SPA refreshes, JavaScript memory is reset, so authentication state must be persisted somewhere
- Token storage location: The choice of storage determines the security model. Whether to store in localStorage, sessionStorage, Cookie, or memory dictates the entire XSS/CSRF defense strategy
- CORS constraints: When frontend and backend are deployed on different domains, cookie and authentication header behavior becomes complex
This article covers everything you need to know when implementing authentication in a React SPA. Let's master storage comparison, authentication state management patterns, Axios interceptors, XSS/CSRF defense, Protected Routes, and Silent Refresh with practical TypeScript code.
Browser Storage Accessibility Table
Where to store tokens is the most important design decision for SPA authentication. You must understand the characteristics of each storage option precisely.
| Storage | JS Access | Auto Server Send | XSS Vulnerable | CSRF Vulnerable |
|---|---|---|---|---|
| HttpOnly Cookie | No | Yes | No | Yes |
| Non-HttpOnly Cookie | Yes | Yes | Yes | Yes |
| localStorage | Yes | No | Yes | No |
| sessionStorage | Yes | No | Yes | No |
| Memory (JS var/state) | Yes | No | Partial | No |
Security Trade-offs of Each Method
HttpOnly Cookie is the most resistant to XSS attacks since JavaScript cannot access it. The browser automatically sends the cookie with every request, so the frontend doesn't need to handle tokens directly. However, due to this automatic sending characteristic, it is vulnerable to CSRF attacks, which must be defended against using CSRF tokens or the SameSite attribute.
localStorage/sessionStorage can be freely accessed by JavaScript, making implementation simple. They are not automatically sent to the server, making them safe from CSRF, but they can be stolen through XSS attacks. sessionStorage disappears when the tab is closed, but while exposed to XSS, it is equally dangerous.
Memory (JS var/state) disappears on refresh, so there is no persistence, but it is the hardest to access directly through XSS attacks (though not impossible). It is also safe from CSRF. The refresh problem can be solved with Silent Refresh.
Recommended combination: HttpOnly Cookie (Access Token or Refresh Token) + CSRF Token + SameSite=Lax/Strict
Authentication State Management Patterns
React Context + useReducer
This is the most basic yet effective approach. It uses only React's built-in features without external libraries.
// types/auth.ts
export interface User {
id: string
email: string
name: string
roles: string[]
}
export interface AuthState {
user: User | null
isAuthenticated: boolean
isLoading: boolean
}
export type AuthAction =
| { type: 'AUTH_START' }
| { type: 'AUTH_SUCCESS'; payload: User }
| { type: 'AUTH_FAILURE' }
| { type: 'LOGOUT' }
// context/AuthContext.tsx
import React, { createContext, useReducer, useContext, useEffect, useCallback } from 'react';
import type { AuthState, AuthAction, User } from '../types/auth';
import { apiClient } from '../lib/apiClient';
const initialState: AuthState = {
user: null,
isAuthenticated: false,
isLoading: true, // Initial loading state
};
function authReducer(state: AuthState, action: AuthAction): AuthState {
switch (action.type) {
case 'AUTH_START':
return { ...state, isLoading: true };
case 'AUTH_SUCCESS':
return { user: action.payload, isAuthenticated: true, isLoading: false };
case 'AUTH_FAILURE':
return { user: null, isAuthenticated: false, isLoading: false };
case 'LOGOUT':
return { user: null, isAuthenticated: false, isLoading: false };
default:
return state;
}
}
interface AuthContextType extends AuthState {
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
refreshUser: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(authReducer, initialState);
// Check authentication state on initial app load
const refreshUser = useCallback(async () => {
dispatch({ type: 'AUTH_START' });
try {
const { data } = await apiClient.get<User>('/api/auth/me');
dispatch({ type: 'AUTH_SUCCESS', payload: data });
} catch {
dispatch({ type: 'AUTH_FAILURE' });
}
}, []);
useEffect(() => {
refreshUser();
}, [refreshUser]);
const login = async (email: string, password: string) => {
dispatch({ type: 'AUTH_START' });
try {
await apiClient.post('/api/auth/login', { email, password });
// Fetch user info after successful login
const { data } = await apiClient.get<User>('/api/auth/me');
dispatch({ type: 'AUTH_SUCCESS', payload: data });
} catch (error) {
dispatch({ type: 'AUTH_FAILURE' });
throw error; // Handle error in component
}
};
const logout = async () => {
try {
await apiClient.post('/api/auth/logout');
} finally {
dispatch({ type: 'LOGOUT' });
}
};
return (
<AuthContext.Provider value={{ ...state, login, logout, refreshUser }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth(): AuthContextType {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
Zustand Pattern (Concise Alternative)
Instead of Context + useReducer, using Zustand can significantly reduce boilerplate.
// store/authStore.ts
import { create } from 'zustand'
import type { User } from '../types/auth'
import { apiClient } from '../lib/apiClient'
interface AuthStore {
user: User | null
isAuthenticated: boolean
isLoading: boolean
login: (email: string, password: string) => Promise<void>
logout: () => Promise<void>
refreshUser: () => Promise<void>
}
export const useAuthStore = create<AuthStore>((set) => ({
user: null,
isAuthenticated: false,
isLoading: true,
login: async (email, password) => {
set({ isLoading: true })
await apiClient.post('/api/auth/login', { email, password })
const { data } = await apiClient.get<User>('/api/auth/me')
set({ user: data, isAuthenticated: true, isLoading: false })
},
logout: async () => {
await apiClient.post('/api/auth/logout')
set({ user: null, isAuthenticated: false, isLoading: false })
},
refreshUser: async () => {
try {
const { data } = await apiClient.get<User>('/api/auth/me')
set({ user: data, isAuthenticated: true, isLoading: false })
} catch {
set({ user: null, isAuthenticated: false, isLoading: false })
}
},
}))
Token Storage Strategy Implementations
Method 1: HttpOnly Cookie (Recommended)
The server sets the token via the Set-Cookie header, and the frontend never directly accesses the token. This is the safest method.
// Server response header (Django/Express, etc.)
// Set-Cookie: access_token=eyJ...; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=900
// Frontend only needs to set withCredentials
const apiClient = axios.create({
baseURL: process.env.REACT_APP_API_URL,
withCredentials: true, // Include cookies in requests
})
// Login
const login = async (email: string, password: string) => {
// Server sets the token via Set-Cookie
await apiClient.post('/api/auth/login', { email, password })
// Don't handle tokens directly! Only fetch user info from server
const { data } = await apiClient.get('/api/auth/me')
return data
}
In this approach, the frontend never knows the token value. When user information is needed, it queries the server via an endpoint like /api/auth/me.
Method 2: In-Memory (Security First)
Keep the Access Token only in a JavaScript variable. Even if XSS searches through storage, no token is found.
// lib/tokenManager.ts
let accessToken: string | null = null
export const tokenManager = {
getToken: (): string | null => accessToken,
setToken: (token: string): void => {
accessToken = token
},
clearToken: (): void => {
accessToken = null
},
}
// Re-issue Access Token using Refresh Token (HttpOnly Cookie) on refresh
const silentRefresh = async (): Promise<string | null> => {
try {
const { data } = await axios.post(
'/api/auth/refresh',
{},
{ withCredentials: true } // Refresh Token is in HttpOnly Cookie
)
tokenManager.setToken(data.accessToken)
return data.accessToken
} catch {
tokenManager.clearToken()
return null
}
}
In this pattern, the Access Token is stored in memory and the Refresh Token in an HttpOnly Cookie. Combining the advantages of both, the Access Token is safe from XSS, and the Refresh Token is protected by CSRF+SameSite.
Method 3: localStorage (Simple but Caution Required)
This is the easiest to implement, but you must be aware that it is vulnerable to XSS.
// lib/tokenStorage.ts
const ACCESS_TOKEN_KEY = 'access_token'
export const tokenStorage = {
getToken: (): string | null => localStorage.getItem(ACCESS_TOKEN_KEY),
setToken: (token: string): void => {
localStorage.setItem(ACCESS_TOKEN_KEY, token)
},
clearToken: (): void => {
localStorage.removeItem(ACCESS_TOKEN_KEY)
},
}
When is localStorage acceptable?
- Trusted environments such as internal admin tools
- When token lifetime is very short (under 5 minutes) and Refresh Token is stored in HttpOnly Cookie
- When CSP is strictly configured making external script injection virtually impossible
When should you avoid localStorage?
- Services that render user input as HTML (bulletin boards, comments, wikis, etc.)
- Pages with many third-party scripts injected
- Financial/healthcare services requiring high security
Axios Interceptor Setup
Set up Axios interceptors, which are the core of authenticated requests. Automatically inject tokens in requests and detect 401 responses to refresh tokens.
// lib/apiClient.ts
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'
import { tokenManager } from './tokenManager'
const apiClient = axios.create({
baseURL: process.env.REACT_APP_API_URL,
timeout: 10000,
withCredentials: true, // Required for Cookie method
})
// --- Request Interceptor ---
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// Add Authorization header for In-Memory or localStorage methods
const token = tokenManager.getToken()
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => Promise.reject(error)
)
// --- Response Interceptor: Detect 401 -> Refresh token -> Retry ---
let isRefreshing = false
let failedQueue: Array<{
resolve: (token: string | null) => void
reject: (error: unknown) => void
}> = []
const processQueue = (error: unknown, token: string | null = null) => {
failedQueue.forEach(({ resolve, reject }) => {
if (error) {
reject(error)
} else {
resolve(token)
}
})
failedQueue = []
}
apiClient.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & {
_retry?: boolean
}
// If not 401 or already retried, propagate error
if (error.response?.status !== 401 || originalRequest._retry) {
return Promise.reject(error)
}
// If already refreshing, add to queue
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({
resolve: (token) => {
if (token && originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${token}`
}
resolve(apiClient(originalRequest))
},
reject,
})
})
}
originalRequest._retry = true
isRefreshing = true
try {
const { data } = await axios.post(
`${process.env.REACT_APP_API_URL}/api/auth/refresh`,
{},
{ withCredentials: true }
)
const newToken = data.accessToken
tokenManager.setToken(newToken)
processQueue(null, newToken)
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${newToken}`
}
return apiClient(originalRequest)
} catch (refreshError) {
processQueue(refreshError, null)
tokenManager.clearToken()
// Redirect to login page
window.location.href = '/login'
return Promise.reject(refreshError)
} finally {
isRefreshing = false
}
}
)
export { apiClient }
When Using the fetch API
If using fetch instead of Axios, set credentials: 'include'.
const response = await fetch(`${API_URL}/api/auth/me`, {
method: 'GET',
credentials: 'include', // Include cookies
headers: {
'Content-Type': 'application/json',
// For In-Memory/localStorage methods
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
})
Login/Logout Flow
Login Component
// components/LoginForm.tsx
import { useState, FormEvent } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
export function LoginForm() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const { login } = useAuth()
const navigate = useNavigate()
const location = useLocation()
// Redirect to the page the user tried to access before login
const from = (location.state as { from?: string })?.from || '/dashboard'
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
setError(null)
setIsSubmitting(true)
try {
await login(email, password)
navigate(from, { replace: true })
} catch (err) {
setError('Invalid email or password.')
} finally {
setIsSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
/>
</div>
<div>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
/>
</div>
{error && (
<p role="alert" style={{ color: 'red' }}>
{error}
</p>
)}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Logging in...' : 'Log in'}
</button>
</form>
)
}
Logout Handling
// components/LogoutButton.tsx
import { useAuth } from '../context/AuthContext'
import { useNavigate } from 'react-router-dom'
export function LogoutButton() {
const { logout } = useAuth()
const navigate = useNavigate()
const handleLogout = async () => {
await logout()
// After logout, server handles HttpOnly Cookie deletion
// Client state is reset in AuthContext
navigate('/login', { replace: true })
}
return <button onClick={handleLogout}>Logout</button>
}
JWT Claims Parsing (Frontend)
You can read claims from Non-HttpOnly tokens or tokens received separately from the server and display them in the UI. However, claims parsed on the frontend are for display purposes only and should never be used for authentication/authorization decisions.
// lib/jwtUtils.ts
import { jwtDecode } from 'jwt-decode'
interface JwtPayload {
sub: string
email: string
name: string
roles: string[]
exp: number
iat: number
}
// Using jwt-decode library
export function parseToken(token: string): JwtPayload | null {
try {
return jwtDecode<JwtPayload>(token)
} catch {
return null
}
}
// Manual parsing without library
export function parseTokenManual(token: string): JwtPayload | null {
try {
const base64Url = token.split('.')[1]
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/')
const jsonPayload = decodeURIComponent(
atob(base64)
.split('')
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
.join('')
)
return JSON.parse(jsonPayload)
} catch {
return null
}
}
// Check token expiration
export function isTokenExpired(token: string, bufferSeconds = 30): boolean {
const payload = parseToken(token)
if (!payload) return true
// Consider expired bufferSeconds before actual expiration
return payload.exp * 1000 < Date.now() + bufferSeconds * 1000
}
// Extract display info for user
export function extractDisplayInfo(token: string) {
const payload = parseToken(token)
if (!payload) return null
return {
name: payload.name,
email: payload.email,
roles: payload.roles,
}
}
Frontend Claims Handling Principles:
- Display: Parsing name, email, profile image URL, etc. for UI display is fine
- Authorization: Roles and permissions must be verified on the server. Using
roles.includes('admin')on the frontend to branch UI is for UX improvement, not security - Expiration check: Checking expiration time on the frontend to trigger early renewal is a good pattern
Protected Route Implementation
Implement a route component that protects pages requiring authentication.
// components/RequireAuth.tsx
import { Navigate, useLocation } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
interface RequireAuthProps {
children: React.ReactNode
requiredRoles?: string[]
}
export function RequireAuth({ children, requiredRoles }: RequireAuthProps) {
const { isAuthenticated, isLoading, user } = useAuth()
const location = useLocation()
// Show loading UI during initial loading
if (isLoading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: '2rem' }}>
<span>Verifying authentication...</span>
</div>
)
}
// Unauthenticated -> redirect to login page, remember current path
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location.pathname }} replace />
}
// Role-based access control
if (requiredRoles && user) {
const hasRequiredRole = requiredRoles.some((role) => user.roles.includes(role))
if (!hasRequiredRole) {
return <Navigate to="/unauthorized" replace />
}
}
return <>{children}</>
}
// App.tsx — Route configuration example
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { AuthProvider } from './context/AuthContext'
import { RequireAuth } from './components/RequireAuth'
import { LoginForm } from './components/LoginForm'
import { Dashboard } from './pages/Dashboard'
import { AdminPanel } from './pages/AdminPanel'
function App() {
return (
<BrowserRouter>
<AuthProvider>
<Routes>
<Route path="/login" element={<LoginForm />} />
<Route
path="/dashboard"
element={
<RequireAuth>
<Dashboard />
</RequireAuth>
}
/>
<Route
path="/admin"
element={
<RequireAuth requiredRoles={['admin']}>
<AdminPanel />
</RequireAuth>
}
/>
</Routes>
</AuthProvider>
</BrowserRouter>
)
}
XSS Defense
Cross-Site Scripting (XSS) attacks are the biggest threat to SPA authentication. If an attacker injects malicious scripts into the page, token theft, cookie access, and keystroke interception become possible.
React's Default Defense
React automatically HTML escapes variables when rendering them in JSX.
// Safe — React auto-escapes
const userInput = '<script>alert("XSS")</script>'
return <div>{userInput}</div>
// Renders: <script>alert("XSS")</script>
dangerouslySetInnerHTML Risks
// DANGEROUS!! — Never directly insert user input
return <div dangerouslySetInnerHTML={{ __html: userInput }} />
// If you must use it, sanitize with DOMPurify
import DOMPurify from 'dompurify'
const sanitized = DOMPurify.sanitize(userInput, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'p', 'br'],
ALLOWED_ATTR: [],
})
return <div dangerouslySetInnerHTML={{ __html: sanitized }} />
Content Security Policy (CSP)
<!-- public/index.html or server response header -->
<meta
http-equiv="Content-Security-Policy"
content="
default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
connect-src 'self' https://api.example.com;
"
/>
Setting CSP can significantly reduce the impact of XSS attacks by blocking external domain script loading and inline script execution.
CSRF Defense
When using cookies, the browser automatically includes them in requests, making it vulnerable to CSRF attacks.
SameSite Cookie Configuration
Set-Cookie: access_token=eyJ...; HttpOnly; Secure; SameSite=Lax; Path=/
SameSite=Strict: Cookies not sent on any cross-site request (including link clicks)SameSite=Lax: Allows GET requests (link clicks), blocks POST/PUT/DELETESameSite=None; Secure: Sends cookies on all cross-origin requests (for cross-domain SSO)
Double Submit Cookie Pattern
// Server delivers CSRF token as Non-HttpOnly Cookie
// Set-Cookie: csrf_token=abc123; Path=/; SameSite=Lax (NOT HttpOnly!)
// Frontend: Read CSRF token from cookie and send as header
function getCsrfToken(): string {
const match = document.cookie.match(/csrf_token=([^;]+)/)
return match ? match[1] : ''
}
// Auto-add in Axios interceptor
apiClient.interceptors.request.use((config) => {
const csrfToken = getCsrfToken()
if (csrfToken && config.headers) {
config.headers['X-CSRF-Token'] = csrfToken
}
return config
})
When Using with Django
Django provides a csrftoken cookie by default. Read it in React and send it as the X-CSRFToken header.
// Django CSRF integration
apiClient.interceptors.request.use((config) => {
const csrfToken = document.cookie
.split('; ')
.find((row) => row.startsWith('csrftoken='))
?.split('=')[1]
if (csrfToken && config.headers) {
config.headers['X-CSRFToken'] = csrfToken
}
return config
})
CORS Configuration Considerations
When the frontend (e.g., localhost:3000) and backend (e.g., localhost:8000) run on different domains/ports, CORS policies apply.
Constraints with credentials: 'include'
// Frontend
axios.defaults.withCredentials = true
// or
fetch(url, { credentials: 'include' })
When using this setting, the server must comply with the following:
# Django example (django-cors-headers)
CORS_ALLOWED_ORIGINS = [
"http://localhost:3000", # Wildcard '*' cannot be used!
]
CORS_ALLOW_CREDENTIALS = True
Access-Control-Allow-Origin: *cannot be used withcredentials: include- A specific domain must be specified
- Preflight (OPTIONS) requests must also return the same headers
Development Environment Proxy Setup
The simplest way to bypass CORS issues during development is a proxy.
// package.json (Create React App)
{
"proxy": "http://localhost:8000"
}
// vite.config.ts (Vite)
export default defineConfig({
server: {
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
})
Using a proxy makes the frontend and backend operate under the same origin, resolving CORS and cookie issues. However, in production, the same effect must be achieved with a reverse proxy like Nginx.
Silent Refresh / Token Rotation
This is how to issue new tokens without disrupting user experience when the Access Token expires.
Refresh Token Rotation Pattern
Each time a Refresh Token is used, a new Refresh Token is issued and the previous one is invalidated. Even if a token is stolen, it can only be used once, minimizing damage.
// lib/silentRefresh.ts
import { apiClient } from './apiClient'
import { tokenManager } from './tokenManager'
let refreshTimer: ReturnType<typeof setTimeout> | null = null
export function scheduleRefresh(expiresInMs: number) {
// Refresh 1 minute before expiration
const refreshTime = expiresInMs - 60 * 1000
if (refreshTimer) {
clearTimeout(refreshTimer)
}
if (refreshTime <= 0) {
// Already near expiration -> refresh immediately
performRefresh()
return
}
refreshTimer = setTimeout(performRefresh, refreshTime)
}
async function performRefresh() {
try {
const { data } = await apiClient.post('/api/auth/refresh')
tokenManager.setToken(data.accessToken)
// Schedule next refresh based on new token's expiration time
scheduleRefresh(data.expiresIn * 1000)
} catch {
tokenManager.clearToken()
window.location.href = '/login'
}
}
export function cancelRefresh() {
if (refreshTimer) {
clearTimeout(refreshTimer)
refreshTimer = null
}
}
setInterval vs Request-time Refresh
| Strategy | Pros | Cons |
|---|---|---|
| setInterval / setTimeout | Refreshes before expiration, no UX hiccup | Runs even in inactive tabs, unnecessary requests |
| Request-time check | Refreshes only when needed, efficient | Slight delay may occur during refresh |
In practice, a hybrid strategy is recommended: check expiration at request time in Axios interceptors and proactively refresh via timer. The Axios interceptor code above handles request-time refresh, while scheduleRefresh handles timer-based refresh.
Checklist
Items that must be verified when implementing React SPA authentication:
- Token storage location decided: HttpOnly Cookie (recommended) or In-Memory + HttpOnly Refresh Cookie
- withCredentials / credentials configured: Set on all API requests when using Cookie method
- Axios interceptor implemented: 401 detection -> token refresh -> retry, race condition prevention
- AuthProvider implemented: Authentication state check on initial app load (
/api/auth/me) - Protected Route implemented: Unauthenticated -> redirect, role-based access control
- CSRF token handling: Double Submit Cookie or SameSite configuration when using cookies
- CSP configured: Strict policies for script-src, connect-src, etc.
- dangerouslySetInnerHTML audited: Check DOMPurify application everywhere it is used
- CORS configuration verified: credentials + specific domains, preflight response confirmed
- Silent Refresh implemented: Auto-refresh before token expiration, Refresh Token Rotation
- Logout handling: Reset state on both server + client, Cookie deletion API
- Error handling: User guidance on network errors, token refresh failures
Common Bugs and Misconceptions
1. "You should never put JWT in localStorage" — Really?
This claim is exaggerated. JWT in localStorage is vulnerable to XSS, but if XSS occurs, even with HttpOnly Cookie method, an attacker can call APIs using the user's session (even without directly stealing the token). The key is preventing XSS, not that storage location alone completes security. That said, HttpOnly Cookie does provide an additional layer of defense-in-depth, so it is recommended.
2. Checking Token Expiration Only on the Frontend
Checking the exp claim on the frontend to avoid sending expired tokens is a UX optimization. However, the server must independently validate token validity. The frontend clock may be wrong, and the token may have been revoked server-side.
3. Cookies Not Being Sent Despite Setting withCredentials
- CORS response missing
Access-Control-Allow-Credentials: true - Using
Access-Control-Allow-Origin: *(credentials and wildcard are incompatible) SameSite=Strictbut requesting from a different domainSecureflag but requesting over HTTP (not HTTPS)- Mixing localhost and 127.0.0.1 in development environment
4. Confusing XSS and CSRF
| XSS | CSRF | |
|---|---|---|
| Attack method | Inject malicious scripts | Forge requests via authenticated user's browser |
| Defense | Input sanitization, CSP, HttpOnly Cookie | CSRF token, SameSite Cookie |
| Token storage | localStorage vulnerable, HttpOnly Cookie safe | All cookies vulnerable, localStorage safe |
These are different attack vectors, and both must be defended against simultaneously.
5. Sending Refresh Token with Every Request Like Access Token
The Refresh Token should only be sent to the token refresh endpoint. Sending the Refresh Token with every API request widens the attack surface. You can enforce this by restricting the cookie's Path attribute to /api/auth/refresh.
6. Only Clearing Client State on Logout
Even if the token is deleted on the client, it is still valid on the server (JWT is stateless). You must call a server logout API to invalidate (revoke) the Refresh Token for a complete logout.
7. Missing isLoading Handling During Initial Load
While AuthProvider calls /api/auth/me to check authentication state, isLoading should be true. Missing this causes a momentary render of the unauthenticated state, followed by a flicker after authentication. Always handle the isLoading state in Protected Routes.
References
- React Official Docs — Context
- OWASP — Cross-Site Scripting (XSS) Prevention Cheat Sheet
- OWASP — Cross-Site Request Forgery (CSRF) Prevention Cheat Sheet
- OWASP — JSON Web Token Cheat Sheet
- RFC 7519 — JSON Web Token (JWT)
- MDN — HTTP cookies
- MDN — SameSite cookies
- Axios Official Docs — Interceptors
- jwt.io — JWT Debugger
- Auth0 Blog — Token Storage in Single Page Applications
- hasura.io — The Ultimate Guide to handling JWTs on frontend clients
- DOMPurify — XSS sanitizer
- React Router v6 — Authentication