- Published on
React SPA 인증 실전 가이드 — 쿠키 vs 스토리지, Axios 인터셉터, XSS/CSRF 방어 완전 정복
- Authors
- Name
- 개요 — SPA 인증의 특수성
- 브라우저 저장소별 접근 가능성 표
- 인증 상태 관리 패턴
- 토큰 저장 전략별 구현
- Axios 인터셉터 설정
- 로그인/로그아웃 플로우
- JWT 클레임 파싱 (프론트엔드)
- Protected Route 구현
- XSS 방어
- CSRF 방어
- CORS 설정 주의사항
- Silent Refresh / Token Rotation
- 체크리스트
- 흔한 버그와 오해
- 참고자료
개요 — 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 })
}
},
}))
토큰 저장 전략별 구현
방법 1: HttpOnly Cookie (권장)
서버가 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,
}
}
프론트엔드 클레임 처리 원칙:
- 표시용(Display): 이름, 이메일, 프로필 이미지 URL 등은 UI에 표시하기 위해 파싱해도 된다
- 인가용(Authorization): 역할, 권한 등은 서버에서 검증해야 한다. 프론트에서
roles.includes('admin')으로 UI를 분기하는 것은 UX 향상 목적이지 보안이 아니다 - 만료 확인: 프론트에서 만료 시간을 확인하여 미리 갱신을 트리거하는 것은 좋은 패턴이다
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>
// 렌더링: <script>alert("XSS")</script>
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 공격에 취약하다.
SameSite Cookie 설정
Set-Cookie: access_token=eyJ...; HttpOnly; Secure; SameSite=Lax; Path=/
SameSite=Strict: 외부 사이트에서의 모든 요청에 쿠키 미전송 (링크 클릭 포함)SameSite=Lax: GET 요청(링크 클릭)은 허용, POST/PUT/DELETE는 차단SameSite=None; Secure: 모든 교차 출처 요청에 쿠키 전송 (크로스 도메인 SSO 시)
Double Submit Cookie 패턴
// 서버가 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 혼동
| XSS | CSRF | |
|---|---|---|
| 공격 방법 | 악성 스크립트 삽입 | 인증된 사용자의 브라우저로 위조 요청 |
| 방어 | 입력 sanitize, CSP, HttpOnly Cookie | CSRF 토큰, 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 공식 문서 — 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 공식 문서 — Interceptors
- jwt.io — JWT 디버거
- 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