> **📚 SSO 쿠키/JWT 인증 시리즈** > [Django 편](/blog/architecture/2026-03-08-sso-cookie-jwt-auth-django) ← 현재: React 편 → [Next.js 편](/blog/architecture/2026-03-08-sso-cookie-jwt-auth-nextjs)
개요 — 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
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 (
{children}
);
}
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
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
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
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 (
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
/>
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
/>
{error && (
{error}
)}
{isSubmitting ? '로그인 중...' : '로그인'}
)
}
로그아웃 처리
// components/LogoutButton.tsx
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
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
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 (
)
}
// 미인증 → 로그인 페이지로, 현재 경로 기억
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 — 라우트 설정 예시
function App() {
return (
path="/dashboard"
element={
}
/>
path="/admin"
element={
}
/>
)
}
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
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 또는 서버 응답 헤더 -->
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
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](https://react.dev/learn/passing-data-deeply-with-context)
- [OWASP — Cross-Site Scripting (XSS) Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Scripting_Prevention_Cheat_Sheet.html)
- [OWASP — Cross-Site Request Forgery (CSRF) Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html)
- [OWASP — JSON Web Token Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html)
- [RFC 7519 — JSON Web Token (JWT)](https://datatracker.ietf.org/doc/html/rfc7519)
- [MDN — HTTP cookies](https://developer.mozilla.org/ko/docs/Web/HTTP/Cookies)
- [MDN — SameSite cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value)
- [Axios 공식 문서 — Interceptors](https://axios-http.com/docs/interceptors)
- [jwt.io — JWT 디버거](https://jwt.io/)
- [Auth0 Blog — Token Storage in Single Page Applications](https://auth0.com/docs/secure/security-guidance/data-security/token-storage)
- [hasura.io — The Ultimate Guide to handling JWTs on frontend clients](https://hasura.io/blog/best-practices-of-using-jwt-with-graphql/)
- [DOMPurify — XSS sanitizer](https://github.com/cure53/DOMPurify)
- [React Router v6 — Authentication](https://reactrouter.com/en/main/start/concepts)
현재 단락 (1/622)
전통적인 Multi-Page Application(MPA)에서는 서버가 세션을 관리하고 매 요청마다 HTML을 렌더링하면서 인증 상태를 자연스럽게 유지했다. 서버 측 템플릿 엔진이...