Skip to content

Split View: React SPA 인증 실전 가이드 — 쿠키 vs 스토리지, Axios 인터셉터, XSS/CSRF 방어 완전 정복

|

React SPA 인증 실전 가이드 — 쿠키 vs 스토리지, Axios 인터셉터, XSS/CSRF 방어 완전 정복

📚 SSO 쿠키/JWT 인증 시리즈 > Django 편 ← 현재: React 편 → Next.js 편

개요 — SPA 인증의 특수성

전통적인 Multi-Page Application(MPA)에서는 서버가 세션을 관리하고 매 요청마다 HTML을 렌더링하면서 인증 상태를 자연스럽게 유지했다. 서버 측 템플릿 엔진이 로그인 여부를 확인하고, 인증되지 않은 사용자는 로그인 페이지로 리다이렉트하면 그만이었다.

React 같은 Single-Page Application(SPA)은 근본적으로 다르다. 브라우저에서 JavaScript가 모든 화면 전환을 처리하고, 서버와는 API 호출로만 통신한다. 이 구조에서 인증은 다음과 같은 고유한 도전을 안고 있다.

  • 상태의 이중성: 서버의 세션/토큰 유효성과 클라이언트의 UI 상태가 분리되어 동기화 문제가 발생한다
  • 페이지 새로고침: SPA에서 새로고침하면 JavaScript 메모리가 초기화되므로 인증 상태를 어딘가에 영속적으로 저장해야 한다
  • 토큰 저장 위치: 저장소 선택이 곧 보안 모델을 결정한다. localStorage, sessionStorage, Cookie, 메모리 중 어디에 저장할 것인가가 XSS/CSRF 방어 전략 전체를 좌우한다
  • CORS 제약: 프론트엔드와 백엔드가 다른 도메인에 배포되는 경우, 쿠키와 인증 헤더의 동작이 복잡해진다

이 글에서는 React SPA에서 인증을 구현할 때 알아야 할 모든 것을 다룬다. 저장소 비교, 인증 상태 관리 패턴, Axios 인터셉터, XSS/CSRF 방어, Protected Route, Silent Refresh까지 실전 TypeScript 코드와 함께 완전 정복하자.

브라우저 저장소별 접근 가능성 표

토큰을 어디에 저장할 것인가는 SPA 인증의 가장 중요한 설계 결정이다. 각 저장소의 특성을 정확히 이해해야 한다.

저장소JS 접근서버 자동 전송XSS 취약CSRF 취약
HttpOnly Cookie
Non-HttpOnly Cookie
localStorage
sessionStorage
Memory (JS 변수/state)

각 방식의 보안 트레이드오프

HttpOnly Cookie는 JavaScript로 접근할 수 없어 XSS 공격에 가장 강하다. 브라우저가 매 요청마다 자동으로 쿠키를 전송하므로 프론트엔드에서 토큰을 직접 다룰 필요가 없다. 단, 자동 전송 특성 때문에 CSRF 공격에 취약하므로 CSRF 토큰이나 SameSite 속성으로 방어해야 한다.

localStorage/sessionStorage는 JavaScript로 자유롭게 접근할 수 있어 구현이 간편하다. 서버에 자동으로 전송되지 않아 CSRF에는 안전하지만, XSS 공격으로 탈취될 수 있다. sessionStorage는 탭을 닫으면 사라지지만, XSS에 노출된 동안에는 동일하게 위험하다.

**메모리(JS 변수/state)**는 새로고침 시 사라지므로 영속성이 없지만, XSS 공격으로 직접 접근하기가 가장 어렵다(불가능하지는 않다). CSRF에도 안전하다. 새로고침 문제는 Silent Refresh로 해결할 수 있다.

권장 조합: HttpOnly Cookie(Access Token 또는 Refresh Token) + CSRF 토큰 + SameSite=Lax/Strict

인증 상태 관리 패턴

React Context + useReducer

가장 기본적이면서도 효과적인 방법이다. 외부 라이브러리 없이 React 내장 기능만으로 구현한다.

// 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, // 초기 로딩 상태
};

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);

  // 앱 초기 로딩 시 인증 상태 확인
  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 });
      // 로그인 성공 후 사용자 정보 조회
      const { data } = await apiClient.get<User>('/api/auth/me');
      dispatch({ type: 'AUTH_SUCCESS', payload: data });
    } catch (error) {
      dispatch({ type: 'AUTH_FAILURE' });
      throw error; // 컴포넌트에서 에러 핸들링
    }
  };

  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 패턴 (간결한 대안)

Context + useReducer 대신 Zustand를 사용하면 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 })
    }
  },
}))

토큰 저장 전략별 구현

서버가 Set-Cookie 헤더로 토큰을 설정하고, 프론트엔드는 토큰에 직접 접근하지 않는다. 가장 안전한 방법이다.

// 서버 응답 헤더 (Django/Express 등)
// Set-Cookie: access_token=eyJ...; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=900

// 프론트엔드에서는 withCredentials만 설정하면 된다
const apiClient = axios.create({
  baseURL: process.env.REACT_APP_API_URL,
  withCredentials: true, // 쿠키를 요청에 포함
})

// 로그인
const login = async (email: string, password: string) => {
  // 서버가 Set-Cookie로 토큰을 설정해 준다
  await apiClient.post('/api/auth/login', { email, password })
  // 토큰을 직접 다루지 않는다! 서버에서 사용자 정보만 조회
  const { data } = await apiClient.get('/api/auth/me')
  return data
}

이 방식에서는 프론트엔드가 토큰 값을 절대 모른다. 사용자 정보가 필요하면 /api/auth/me 같은 엔드포인트로 서버에 질의한다.

방법 2: In-Memory (보안 우선)

Access Token을 JavaScript 변수에만 보관한다. XSS로 저장소를 뒤져도 토큰이 없다.

// 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
  },
}
// 새로고침 시 Refresh Token(HttpOnly Cookie)으로 Access Token 재발급
const silentRefresh = async (): Promise<string | null> => {
  try {
    const { data } = await axios.post(
      '/api/auth/refresh',
      {},
      { withCredentials: true } // Refresh Token은 HttpOnly Cookie
    )
    tokenManager.setToken(data.accessToken)
    return data.accessToken
  } catch {
    tokenManager.clearToken()
    return null
  }
}

이 패턴에서는 Access Token은 메모리에, Refresh Token은 HttpOnly Cookie에 저장한다. 두 가지 장점을 결합하여 Access Token은 XSS에 안전하고, Refresh Token은 CSRF+SameSite로 보호된다.

방법 3: localStorage (간단하지만 주의)

가장 구현이 쉽지만, 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)
  },
}

언제 localStorage가 괜찮은가?

  • 내부 어드민 도구 등 신뢰할 수 있는 환경
  • 토큰 수명이 매우 짧고(5분 이하), Refresh Token은 HttpOnly Cookie에 저장하는 경우
  • CSP가 엄격하게 설정되어 외부 스크립트 삽입이 사실상 불가능한 경우

언제 localStorage를 피해야 하는가?

  • 사용자 입력을 HTML로 렌더링하는 서비스(게시판, 댓글, 위키 등)
  • 서드파티 스크립트가 많이 삽입되는 페이지
  • 높은 보안이 요구되는 금융/의료 서비스

Axios 인터셉터 설정

인증 요청의 핵심인 Axios 인터셉터를 설정한다. Request에서 토큰을 자동 삽입하고, Response에서 401을 감지하여 토큰을 갱신한다.

// 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, // Cookie 방식일 때 필수
})

// ─── Request Interceptor ──────────────────────────────────
apiClient.interceptors.request.use(
  (config: InternalAxiosRequestConfig) => {
    // In-Memory 또는 localStorage 방식일 때 Authorization 헤더 추가
    const token = tokenManager.getToken()
    if (token && config.headers) {
      config.headers.Authorization = `Bearer ${token}`
    }
    return config
  },
  (error) => Promise.reject(error)
)

// ─── Response Interceptor: 401 감지 → 토큰 리프레시 → 재시도 ───
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
    }

    // 401이 아니거나, 이미 재시도한 요청이면 에러 전파
    if (error.response?.status !== 401 || originalRequest._retry) {
      return Promise.reject(error)
    }

    // 이미 리프레시 중이면 큐에 추가
    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()
      // 로그인 페이지로 리다이렉트
      window.location.href = '/login'
      return Promise.reject(refreshError)
    } finally {
      isRefreshing = false
    }
  }
)

export { apiClient }

fetch API 사용 시

Axios 대신 fetch를 사용하는 경우 credentials: 'include'를 설정한다.

const response = await fetch(`${API_URL}/api/auth/me`, {
  method: 'GET',
  credentials: 'include', // 쿠키 포함
  headers: {
    'Content-Type': 'application/json',
    // In-Memory/localStorage 방식일 때
    ...(token ? { Authorization: `Bearer ${token}` } : {}),
  },
})

로그인/로그아웃 플로우

로그인 컴포넌트

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

  // 로그인 전에 접근하려던 페이지가 있으면 그곳으로 리다이렉트
  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('이메일 또는 비밀번호가 올바르지 않습니다.')
    } finally {
      setIsSubmitting(false)
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="email">이메일</label>
        <input
          id="email"
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          required
          autoComplete="email"
        />
      </div>
      <div>
        <label htmlFor="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 ? '로그인 중...' : '로그인'}
      </button>
    </form>
  )
}

로그아웃 처리

// 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()
    // 로그아웃 후 서버에서 HttpOnly Cookie 삭제를 처리
    // 클라이언트 상태는 AuthContext에서 초기화됨
    navigate('/login', { replace: true })
  }

  return <button onClick={handleLogout}>로그아웃</button>
}

JWT 클레임 파싱 (프론트엔드)

Non-HttpOnly 토큰이나 서버에서 별도로 전달받은 토큰의 클레임을 읽어 UI에 표시할 수 있다. 단, 프론트에서 파싱한 클레임은 표시 용도일 뿐 인증/인가 판단에 사용해서는 안 된다.

// lib/jwtUtils.ts
import { jwtDecode } from 'jwt-decode'

interface JwtPayload {
  sub: string
  email: string
  name: string
  roles: string[]
  exp: number
  iat: number
}

// jwt-decode 라이브러리 사용
export function parseToken(token: string): JwtPayload | null {
  try {
    return jwtDecode<JwtPayload>(token)
  } catch {
    return null
  }
}

// 라이브러리 없이 직접 파싱
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
  }
}

// 토큰 만료 여부 확인
export function isTokenExpired(token: string, bufferSeconds = 30): boolean {
  const payload = parseToken(token)
  if (!payload) return true
  // 만료 bufferSeconds 전부터 만료로 간주
  return payload.exp * 1000 < Date.now() + bufferSeconds * 1000
}

// 사용자 표시용 정보 추출
export function extractDisplayInfo(token: string) {
  const payload = parseToken(token)
  if (!payload) return null
  return {
    name: payload.name,
    email: payload.email,
    roles: payload.roles,
  }
}

프론트엔드 클레임 처리 원칙:

  1. 표시용(Display): 이름, 이메일, 프로필 이미지 URL 등은 UI에 표시하기 위해 파싱해도 된다
  2. 인가용(Authorization): 역할, 권한 등은 서버에서 검증해야 한다. 프론트에서 roles.includes('admin')으로 UI를 분기하는 것은 UX 향상 목적이지 보안이 아니다
  3. 만료 확인: 프론트에서 만료 시간을 확인하여 미리 갱신을 트리거하는 것은 좋은 패턴이다

Protected Route 구현

인증이 필요한 페이지를 보호하는 라우트 컴포넌트를 구현한다.

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

  // 초기 로딩 중에는 로딩 UI 표시
  if (isLoading) {
    return (
      <div style={{ display: 'flex', justifyContent: 'center', padding: '2rem' }}>
        <span>인증 확인 중...</span>
      </div>
    )
  }

  // 미인증 → 로그인 페이지로, 현재 경로 기억
  if (!isAuthenticated) {
    return <Navigate to="/login" state={{ from: location.pathname }} replace />
  }

  // 역할 기반 접근 제어
  if (requiredRoles && user) {
    const hasRequiredRole = requiredRoles.some((role) => user.roles.includes(role))
    if (!hasRequiredRole) {
      return <Navigate to="/unauthorized" replace />
    }
  }

  return <>{children}</>
}
// App.tsx — 라우트 설정 예시
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 방어

Cross-Site Scripting(XSS) 공격은 SPA 인증의 가장 큰 위협이다. 공격자가 페이지에 악성 스크립트를 삽입하면, 토큰 탈취, 쿠키 접근, 키 입력 가로채기 등이 가능하다.

React의 기본 방어

React는 JSX에서 변수를 렌더링할 때 자동으로 HTML escape 처리한다.

// 안전 — React가 자동 escape
const userInput = '<script>alert("XSS")</script>'
return <div>{userInput}</div>
// 렌더링: &lt;script&gt;alert("XSS")&lt;/script&gt;

dangerouslySetInnerHTML 위험성

// 위험!! — 절대 사용자 입력을 직접 넣지 말 것
return <div dangerouslySetInnerHTML={{ __html: userInput }} />

// 반드시 사용해야 한다면 DOMPurify로 sanitize
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 또는 서버 응답 헤더 -->
<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;
  "
/>

CSP를 설정하면 외부 도메인의 스크립트 로드, 인라인 스크립트 실행을 차단하여 XSS 공격의 영향을 크게 줄일 수 있다.

CSRF 방어

Cookie를 사용하면 브라우저가 요청에 자동으로 쿠키를 포함하므로 CSRF 공격에 취약하다.

Set-Cookie: access_token=eyJ...; HttpOnly; Secure; SameSite=Lax; Path=/
  • SameSite=Strict: 외부 사이트에서의 모든 요청에 쿠키 미전송 (링크 클릭 포함)
  • SameSite=Lax: GET 요청(링크 클릭)은 허용, POST/PUT/DELETE는 차단
  • SameSite=None; Secure: 모든 교차 출처 요청에 쿠키 전송 (크로스 도메인 SSO 시)
// 서버가 CSRF 토큰을 Non-HttpOnly Cookie로 전달
// Set-Cookie: csrf_token=abc123; Path=/; SameSite=Lax (HttpOnly 아님!)

// 프론트엔드: 쿠키에서 CSRF 토큰 읽어서 헤더로 전송
function getCsrfToken(): string {
  const match = document.cookie.match(/csrf_token=([^;]+)/)
  return match ? match[1] : ''
}

// Axios interceptor에서 자동 추가
apiClient.interceptors.request.use((config) => {
  const csrfToken = getCsrfToken()
  if (csrfToken && config.headers) {
    config.headers['X-CSRF-Token'] = csrfToken
  }
  return config
})

Django와 함께 사용할 때

Django는 csrftoken 쿠키를 기본 제공한다. React에서 이를 읽어 X-CSRFToken 헤더로 전송하면 된다.

// Django CSRF 연동
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 설정 주의사항

프론트엔드(예: localhost:3000)와 백엔드(예: localhost:8000)가 다른 도메인/포트에서 실행되면 CORS 정책이 적용된다.

credentials: 'include' 시 제약

// 프론트엔드
axios.defaults.withCredentials = true
// 또는
fetch(url, { credentials: 'include' })

이 설정을 사용하면 서버 측에서 반드시 다음을 준수해야 한다:

# Django 예시 (django-cors-headers)
CORS_ALLOWED_ORIGINS = [
    "http://localhost:3000",  # 와일드카드 '*' 사용 불가!
]
CORS_ALLOW_CREDENTIALS = True
  • Access-Control-Allow-Origin: *credentials: include와 함께 사용할 수 없다
  • 반드시 구체적인 도메인을 지정해야 한다
  • Preflight(OPTIONS) 요청도 같은 헤더를 반환해야 한다

개발 환경 프록시 설정

CORS 문제를 개발 단계에서 우회하는 가장 간단한 방법은 프록시다.

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

프록시를 사용하면 프론트엔드와 백엔드가 같은 origin으로 동작하므로 CORS와 쿠키 문제가 해결된다. 단, 프로덕션에서는 Nginx 등 리버스 프록시로 동일한 효과를 구현해야 한다.

Silent Refresh / Token Rotation

Access Token이 만료되었을 때 사용자 경험을 해치지 않고 새 토큰을 발급받는 방법이다.

Refresh Token Rotation 패턴

Refresh Token을 사용할 때마다 새로운 Refresh Token을 발급하고, 이전 토큰은 무효화한다. 토큰이 탈취되더라도 한 번만 사용 가능하므로 피해를 최소화할 수 있다.

// lib/silentRefresh.ts
import { apiClient } from './apiClient'
import { tokenManager } from './tokenManager'

let refreshTimer: ReturnType<typeof setTimeout> | null = null

export function scheduleRefresh(expiresInMs: number) {
  // 만료 1분 전에 갱신
  const refreshTime = expiresInMs - 60 * 1000

  if (refreshTimer) {
    clearTimeout(refreshTimer)
  }

  if (refreshTime <= 0) {
    // 이미 만료 임박 → 즉시 갱신
    performRefresh()
    return
  }

  refreshTimer = setTimeout(performRefresh, refreshTime)
}

async function performRefresh() {
  try {
    const { data } = await apiClient.post('/api/auth/refresh')
    tokenManager.setToken(data.accessToken)
    // 새 토큰의 만료 시간으로 다음 갱신 스케줄
    scheduleRefresh(data.expiresIn * 1000)
  } catch {
    tokenManager.clearToken()
    window.location.href = '/login'
  }
}

export function cancelRefresh() {
  if (refreshTimer) {
    clearTimeout(refreshTimer)
    refreshTimer = null
  }
}

setInterval vs 요청 시점 갱신

전략장점단점
setInterval / setTimeout만료 전 미리 갱신, UX 끊김 없음비활성 탭에서도 실행, 불필요한 네트워크 요청
요청 시점 확인필요할 때만 갱신, 효율적갱신 중 약간의 딜레이 발생 가능

실전에서는 Axios 인터셉터에서 요청 시점에 만료를 확인하고, 타이머로 미리 갱신하는 하이브리드 전략을 권장한다. 위의 Axios 인터셉터 코드가 요청 시점 갱신을, scheduleRefresh가 타이머 기반 갱신을 담당한다.

체크리스트

React SPA 인증 구현 시 반드시 확인해야 할 항목들이다.

  • 토큰 저장 위치 결정: HttpOnly Cookie(권장) 또는 In-Memory + HttpOnly Refresh Cookie
  • withCredentials / credentials 설정: Cookie 방식 사용 시 모든 API 요청에 설정
  • Axios 인터셉터 구현: 401 감지 → 토큰 갱신 → 재시도, Race condition 방지
  • AuthProvider 구현: 앱 초기 로딩 시 인증 상태 확인 (/api/auth/me)
  • Protected Route 구현: 미인증 → 리다이렉트, 역할 기반 접근 제어
  • CSRF 토큰 처리: Cookie 사용 시 Double Submit Cookie 또는 SameSite 설정
  • CSP 설정: script-src, connect-src 등 엄격한 정책 적용
  • dangerouslySetInnerHTML 감사: 사용하는 곳마다 DOMPurify 적용 여부 확인
  • CORS 설정 확인: credentials + 구체적 도메인, preflight 응답 확인
  • Silent Refresh 구현: 토큰 만료 전 자동 갱신, Refresh Token Rotation
  • 로그아웃 처리: 서버 + 클라이언트 양쪽 상태 초기화, Cookie 삭제 API
  • 에러 핸들링: 네트워크 오류, 토큰 갱신 실패 시 사용자 안내

흔한 버그와 오해

1. "JWT를 localStorage에 넣으면 절대 안 된다" — 진짜?

이 주장은 과장이다. localStorage의 JWT는 XSS에 취약하지만, XSS가 발생하면 HttpOnly Cookie 방식이라도 공격자는 사용자 세션으로 API를 호출할 수 있다(토큰을 직접 탈취하지 않더라도). 핵심은 XSS를 방지하는 것이지, 저장 위치만으로 보안이 완성되는 것이 아니다. 그렇다고 해도 HttpOnly Cookie가 방어 심층에서 한 단계 더 보호해 주는 것은 사실이므로 권장된다.

2. 토큰 만료 체크를 프론트에서만 하기

프론트에서 exp 클레임을 확인하여 만료된 토큰을 보내지 않는 것은 UX 최적화다. 하지만 서버는 반드시 독립적으로 토큰 유효성을 검증해야 한다. 프론트의 시계가 틀릴 수 있고, 토큰이 서버 측에서 revoke되었을 수 있다.

3. withCredentials를 설정했는데 쿠키가 안 간다

  • CORS 응답에 Access-Control-Allow-Credentials: true가 없는 경우
  • Access-Control-Allow-Origin: *를 사용한 경우 (credentials와 와일드카드는 공존 불가)
  • SameSite=Strict인데 다른 도메인에서 요청하는 경우
  • Secure 플래그인데 HTTP(비 HTTPS)로 요청하는 경우
  • 개발 환경에서 localhost와 127.0.0.1을 혼용하는 경우

4. XSS와 CSRF 혼동

XSSCSRF
공격 방법악성 스크립트 삽입인증된 사용자의 브라우저로 위조 요청
방어입력 sanitize, CSP, HttpOnly CookieCSRF 토큰, SameSite Cookie
토큰 저장 관련localStorage 취약, HttpOnly Cookie 안전Cookie 전체 취약, localStorage 안전

이 둘은 서로 다른 공격 벡터이며, 둘 다 동시에 방어해야 한다.

5. Refresh Token을 Access Token처럼 매 요청에 보내기

Refresh Token은 토큰 갱신 엔드포인트에만 전송해야 한다. 모든 API 요청에 Refresh Token을 보내면 공격 표면이 넓어진다. 쿠키의 Path 속성을 /api/auth/refresh로 제한하여 이를 강제할 수 있다.

6. 로그아웃 시 클라이언트 상태만 지우기

클라이언트에서 토큰을 삭제해도 서버에서는 여전히 유효하다(JWT는 stateless). 서버에 로그아웃 API를 호출하여 Refresh Token을 무효화(revoke)해야 완전한 로그아웃이 된다.

7. 초기 로딩 시 isLoading 처리 누락

AuthProvider가 /api/auth/me를 호출하여 인증 상태를 확인하는 동안, isLoading이 true여야 한다. 이를 누락하면 미인증 상태로 잠깐 렌더링되었다가 인증 후 깜빡이는 현상이 발생한다. Protected Route에서 isLoading 상태를 반드시 처리하자.

참고자료

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

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.

StorageJS AccessAuto Server SendXSS VulnerableCSRF Vulnerable
HttpOnly CookieNoYesNoYes
Non-HttpOnly CookieYesYesYesYes
localStorageYesNoYesNo
sessionStorageYesNoYesNo
Memory (JS var/state)YesNoPartialNo

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

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:

  1. Display: Parsing name, email, profile image URL, etc. for UI display is fine
  2. 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
  3. 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: &lt;script&gt;alert("XSS")&lt;/script&gt;

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.

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/DELETE
  • SameSite=None; Secure: Sends cookies on all cross-origin requests (for cross-domain SSO)
// 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 with credentials: 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

StrategyProsCons
setInterval / setTimeoutRefreshes before expiration, no UX hiccupRuns even in inactive tabs, unnecessary requests
Request-time checkRefreshes only when needed, efficientSlight 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=Strict but requesting from a different domain
  • Secure flag but requesting over HTTP (not HTTPS)
  • Mixing localhost and 127.0.0.1 in development environment

4. Confusing XSS and CSRF

XSSCSRF
Attack methodInject malicious scriptsForge requests via authenticated user's browser
DefenseInput sanitization, CSP, HttpOnly CookieCSRF token, SameSite Cookie
Token storagelocalStorage vulnerable, HttpOnly Cookie safeAll 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