Django & Next.js JWT 인증 완벽 가이드: Access/Refresh 토큰 관리부터 보안까지
JWT(JSON Web Token)는 현대 웹 애플리케이션에서 가장 널리 사용되는 인증 방식입니다. 이 글에서는 Django 백엔드와 Next.js 프론트엔드 환경에서 JWT를 활용한 인증/인가 시스템을 구축하고, 실무에서 마주하는 다양한 환경(개발/스테이징/프로덕션)과 플랫폼(웹/모바일웹/앱)에서 안전하게 토큰을 관리하는 방법을 상세히 다룹니다.
🎯 JWT 인증의 전체 흐름 이해하기
JWT란 무엇인가?
JWT는 세 부분으로 구성된 토큰 형식입니다:
Header.Payload.Signature
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxMjMsImV4cCI6MTY0MDk5NTIwMH0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Header: 토큰 타입과 해싱 알고리즘 정보 Payload: 사용자 정보와 메타데이터 (claims) Signature: 토큰의 무결성을 검증하는 서명
Access Token과 Refresh Token의 역할
Access Token (짧은 수명: 15분 ~ 1시간)
- API 요청 시 인증에 사용
- 짧은 수명으로 보안 강화
- 탈취되더라도 피해 최소화
Refresh Token (긴 수명: 7일 ~ 30일)
- Access Token 갱신 전용
- 안전한 저장소에 보관 필수
- 한 번만 사용 가능하도록 구현 권장 (Rotation)
🔧 Django 백엔드 JWT 구현
1. 기본 설정 및 패키지 설치
# Django REST Framework와 JWT 라이브러리 설치
pip install django djangorestframework djangorestframework-simplejwt
pip install django-cors-headers # CORS 설정용
2. Django 설정 (settings.py)
# settings.py
from datetime import timedelta
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
# Third party
'rest_framework',
'rest_framework_simplejwt',
'corsheaders',
# Your apps
'accounts',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'corsheaders.middleware.CorsMiddleware', # CORS를 최상단에 배치
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
# REST Framework 설정
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication',
),
'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.IsAuthenticated',
),
}
# JWT 설정
SIMPLE_JWT = {
# Access Token 수명 (15분)
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=15),
# Refresh Token 수명 (7일)
'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
# Refresh Token 자동 갱신 (True 권장)
'ROTATE_REFRESH_TOKENS': True,
# 사용된 Refresh Token 블랙리스트 추가
'BLACKLIST_AFTER_ROTATION': True,
# 알고리즘
'ALGORITHM': 'HS256',
# 서명 키 (환경변수로 관리 필수!)
'SIGNING_KEY': os.environ.get('JWT_SECRET_KEY', SECRET_KEY),
# 토큰 타입
'AUTH_HEADER_TYPES': ('Bearer',),
'AUTH_HEADER_NAME': 'HTTP_AUTHORIZATION',
# Payload에 포함할 정보
'USER_ID_FIELD': 'id',
'USER_ID_CLAIM': 'user_id',
# 토큰 검증 설정
'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',),
'TOKEN_TYPE_CLAIM': 'token_type',
}
# 환경별 CORS 설정
if os.environ.get('ENVIRONMENT') == 'production':
CORS_ALLOWED_ORIGINS = [
'https://yourdomain.com',
'https://www.yourdomain.com',
]
CORS_ALLOW_CREDENTIALS = True
# 프로덕션 쿠키 설정
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SAMESITE = 'None' # Cross-site 쿠키 허용
CSRF_COOKIE_SAMESITE = 'None'
elif os.environ.get('ENVIRONMENT') == 'staging':
CORS_ALLOWED_ORIGINS = [
'https://staging.yourdomain.com',
]
CORS_ALLOW_CREDENTIALS = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
else: # 로컬 개발 환경
CORS_ALLOWED_ORIGINS = [
'http://localhost:3000',
'http://127.0.0.1:3000',
]
CORS_ALLOW_CREDENTIALS = True
SESSION_COOKIE_SECURE = False
CSRF_COOKIE_SECURE = False
3. 커스텀 JWT Views 구현
# accounts/views.py
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework_simplejwt.tokens import RefreshToken
from rest_framework_simplejwt.exceptions import TokenError, InvalidToken
from django.contrib.auth import authenticate
from django.conf import settings
import logging
logger = logging.getLogger(__name__)
@api_view(['POST'])
@permission_classes([AllowAny])
def login(request):
"""
로그인 API
- Username과 Password로 인증
- HttpOnly 쿠키에 Refresh Token 저장
- Response body에 Access Token 반환
"""
username = request.data.get('username')
password = request.data.get('password')
if not username or not password:
return Response(
{'error': 'Username and password are required'},
status=status.HTTP_400_BAD_REQUEST
)
# 사용자 인증
user = authenticate(username=username, password=password)
if user is None:
return Response(
{'error': 'Invalid credentials'},
status=status.HTTP_401_UNAUTHORIZED
)
# JWT 토큰 생성
refresh = RefreshToken.for_user(user)
access_token = str(refresh.access_token)
refresh_token = str(refresh)
# Response 생성
response = Response({
'access': access_token,
'user': {
'id': user.id,
'username': user.username,
'email': user.email,
}
}, status=status.HTTP_200_OK)
# Refresh Token을 HttpOnly 쿠키에 저장
# HttpOnly: JavaScript에서 접근 불가 (XSS 방어)
# Secure: HTTPS에서만 전송 (중간자 공격 방어)
# SameSite: CSRF 방어
response.set_cookie(
key='refresh_token',
value=refresh_token,
httponly=True, # XSS 공격 방어
secure=settings.SESSION_COOKIE_SECURE, # 환경별 설정
samesite=settings.SESSION_COOKIE_SAMESITE, # CSRF 공격 방어
max_age=60 * 60 * 24 * 7, # 7일
path='/api/auth/', # Refresh 경로에서만 전송
)
logger.info(f"User {user.username} logged in successfully")
return response
@api_view(['POST'])
@permission_classes([AllowAny])
def refresh_token(request):
"""
Access Token 갱신 API
- HttpOnly 쿠키에서 Refresh Token 추출
- 새로운 Access Token 발급
- Refresh Token Rotation 적용 시 새로운 Refresh Token도 발급
"""
# 쿠키에서 Refresh Token 추출
refresh_token = request.COOKIES.get('refresh_token')
if not refresh_token:
return Response(
{'error': 'Refresh token not found'},
status=status.HTTP_401_UNAUTHORIZED
)
try:
# Refresh Token 검증 및 새 토큰 발급
refresh = RefreshToken(refresh_token)
access_token = str(refresh.access_token)
response = Response({
'access': access_token,
}, status=status.HTTP_200_OK)
# Rotation이 활성화된 경우, 새로운 Refresh Token도 발급
if settings.SIMPLE_JWT.get('ROTATE_REFRESH_TOKENS'):
# 기존 토큰을 블랙리스트에 추가
if settings.SIMPLE_JWT.get('BLACKLIST_AFTER_ROTATION'):
try:
refresh.blacklist()
except AttributeError:
# Blacklist가 설치되지 않은 경우
pass
# 새로운 Refresh Token 발급
new_refresh = RefreshToken.for_user(refresh.user)
new_refresh_token = str(new_refresh)
# 새로운 Refresh Token을 쿠키에 저장
response.set_cookie(
key='refresh_token',
value=new_refresh_token,
httponly=True,
secure=settings.SESSION_COOKIE_SECURE,
samesite=settings.SESSION_COOKIE_SAMESITE,
max_age=60 * 60 * 24 * 7,
path='/api/auth/',
)
logger.info(f"Access token refreshed for user {refresh.user.username}")
return response
except TokenError as e:
logger.warning(f"Invalid refresh token: {str(e)}")
return Response(
{'error': 'Invalid or expired refresh token'},
status=status.HTTP_401_UNAUTHORIZED
)
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def logout(request):
"""
로그아웃 API
- Refresh Token을 블랙리스트에 추가
- 쿠키에서 Refresh Token 삭제
"""
try:
refresh_token = request.COOKIES.get('refresh_token')
if refresh_token:
token = RefreshToken(refresh_token)
# Refresh Token을 블랙리스트에 추가
token.blacklist()
response = Response(
{'message': 'Logout successful'},
status=status.HTTP_200_OK
)
# 쿠키에서 Refresh Token 삭제
response.delete_cookie('refresh_token', path='/api/auth/')
logger.info(f"User {request.user.username} logged out")
return response
except Exception as e:
logger.error(f"Logout error: {str(e)}")
return Response(
{'error': 'Logout failed'},
status=status.HTTP_400_BAD_REQUEST
)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def verify_token(request):
"""
토큰 검증 API
- Access Token의 유효성 확인
- 사용자 정보 반환
"""
return Response({
'valid': True,
'user': {
'id': request.user.id,
'username': request.user.username,
'email': request.user.email,
}
})
@api_view(['POST'])
@permission_classes([AllowAny])
def check_token(request):
"""
토큰 변조 검증 API
- 클라이언트에서 받은 토큰을 검증
- 변조된 토큰 탐지
"""
token = request.data.get('token')
if not token:
return Response(
{'error': 'Token is required'},
status=status.HTTP_400_BAD_REQUEST
)
try:
# 토큰 검증 (서명 확인, 만료 시간 확인)
from rest_framework_simplejwt.tokens import AccessToken
AccessToken(token)
return Response({
'valid': True,
'message': 'Token is valid'
})
except TokenError as e:
# 토큰이 변조되었거나 만료됨
logger.warning(f"Invalid token detected: {str(e)}")
return Response({
'valid': False,
'error': str(e),
'message': 'Token is invalid or expired'
}, status=status.HTTP_401_UNAUTHORIZED)
4. URL 라우팅 설정
# accounts/urls.py
from django.urls import path
from . import views
app_name = 'accounts'
urlpatterns = [
path('login/', views.login, name='login'),
path('logout/', views.logout, name='logout'),
path('refresh/', views.refresh_token, name='refresh'),
path('verify/', views.verify_token, name='verify'),
path('check/', views.check_token, name='check'),
]
# project/urls.py
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('api/auth/', include('accounts.urls')),
]
5. 커스텀 미들웨어: 자동 토큰 갱신
# accounts/middleware.py
from django.utils.deprecation import MiddlewareMixin
from rest_framework_simplejwt.tokens import RefreshToken, TokenError
from rest_framework_simplejwt.authentication import JWTAuthentication
from django.conf import settings
import logging
logger = logging.getLogger(__name__)
class JWTRefreshMiddleware(MiddlewareMixin):
"""
Access Token이 곧 만료될 경우 자동으로 갱신하는 미들웨어
- Access Token의 남은 수명이 5분 이하일 때 자동 갱신
- Response에 새 토큰을 X-New-Access-Token 헤더로 전달
"""
def process_request(self, request):
"""요청 처리 전"""
pass
def process_response(self, request, response):
"""응답 처리 후"""
# Authorization 헤더에서 토큰 추출
auth_header = request.META.get('HTTP_AUTHORIZATION')
if not auth_header or not auth_header.startswith('Bearer '):
return response
try:
# Access Token 추출 및 검증
token = auth_header.split(' ')[1]
jwt_auth = JWTAuthentication()
validated_token = jwt_auth.get_validated_token(token)
# 토큰의 남은 수명 확인
from datetime import datetime, timezone
exp_timestamp = validated_token['exp']
exp_datetime = datetime.fromtimestamp(exp_timestamp, tz=timezone.utc)
now = datetime.now(timezone.utc)
time_until_expiry = (exp_datetime - now).total_seconds()
# 5분 이하로 남았을 때 자동 갱신
if time_until_expiry < 300: # 5분 = 300초
refresh_token = request.COOKIES.get('refresh_token')
if refresh_token:
try:
refresh = RefreshToken(refresh_token)
new_access_token = str(refresh.access_token)
# 새 토큰을 헤더에 추가
response['X-New-Access-Token'] = new_access_token
logger.info(f"Access token auto-refreshed for user")
except TokenError:
logger.warning("Failed to refresh token automatically")
except Exception as e:
logger.debug(f"Token refresh check failed: {str(e)}")
return response
미들웨어를 settings.py에 추가:
# settings.py
MIDDLEWARE = [
# ... 기존 미들웨어들
'accounts.middleware.JWTRefreshMiddleware', # JWT 자동 갱신
]
🎨 Next.js 프론트엔드 JWT 구현
1. API 클라이언트 구성
// lib/api/client.ts
import axios, { AxiosInstance, AxiosRequestConfig, AxiosError } from 'axios';
// 환경별 API URL 설정
const getBaseURL = () => {
if (process.env.NEXT_PUBLIC_ENVIRONMENT === 'production') {
return 'https://api.yourdomain.com';
} else if (process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging') {
return 'https://api.staging.yourdomain.com';
} else {
return 'http://localhost:8000';
}
};
// Axios 인스턴스 생성
const apiClient: AxiosInstance = axios.create({
baseURL: getBaseURL(),
timeout: 10000,
withCredentials: true, // 쿠키 전송 활성화
headers: {
'Content-Type': 'application/json',
},
});
// Access Token 저장소 (메모리)
let accessToken: string | null = null;
export const setAccessToken = (token: string | null) => {
accessToken = token;
};
export const getAccessToken = () => {
return accessToken;
};
// Request Interceptor: 모든 요청에 Access Token 추가
apiClient.interceptors.request.use(
(config) => {
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response Interceptor: 토큰 만료 시 자동 갱신
let isRefreshing = false;
let failedQueue: Array<{
resolve: (value?: any) => void;
reject: (reason?: any) => void;
}> = [];
const processQueue = (error: any, token: string | null = null) => {
failedQueue.forEach((prom) => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
failedQueue = [];
};
apiClient.interceptors.response.use(
(response) => {
// 자동 갱신된 토큰이 있으면 업데이트
const newAccessToken = response.headers['x-new-access-token'];
if (newAccessToken) {
setAccessToken(newAccessToken);
console.log('Access token auto-refreshed');
}
return response;
},
async (error: AxiosError) => {
const originalRequest = error.config as AxiosRequestConfig & {
_retry?: boolean;
};
// 401 에러이고 재시도하지 않은 요청인 경우
if (error.response?.status === 401 && !originalRequest._retry) {
// Refresh 요청 자체가 실패한 경우는 재시도하지 않음
if (originalRequest.url?.includes('/api/auth/refresh/')) {
return Promise.reject(error);
}
if (isRefreshing) {
// 이미 토큰 갱신 중이면 대기열에 추가
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
.then((token) => {
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${token}`;
}
return apiClient(originalRequest);
})
.catch((err) => {
return Promise.reject(err);
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
// Refresh Token으로 새 Access Token 발급
const response = await apiClient.post('/api/auth/refresh/');
const newAccessToken = response.data.access;
setAccessToken(newAccessToken);
processQueue(null, newAccessToken);
// 실패했던 요청 재시도
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
}
return apiClient(originalRequest);
} catch (refreshError) {
// Refresh Token도 만료된 경우
processQueue(refreshError, null);
setAccessToken(null);
// 로그인 페이지로 리다이렉트
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);
export default apiClient;
2. 인증 서비스 구현
// lib/api/auth.ts
import apiClient, { setAccessToken, getAccessToken } from './client';
export interface LoginCredentials {
username: string;
password: string;
}
export interface User {
id: number;
username: string;
email: string;
}
export interface LoginResponse {
access: string;
user: User;
}
export const authService = {
/**
* 로그인
*/
async login(credentials: LoginCredentials): Promise<LoginResponse> {
const response = await apiClient.post<LoginResponse>(
'/api/auth/login/',
credentials
);
// Access Token을 메모리에 저장
setAccessToken(response.data.access);
// 사용자 정보를 로컬스토리지에 저장 (선택사항)
if (typeof window !== 'undefined') {
localStorage.setItem('user', JSON.stringify(response.data.user));
}
return response.data;
},
/**
* 로그아웃
*/
async logout(): Promise<void> {
try {
await apiClient.post('/api/auth/logout/');
} finally {
// 토큰 및 사용자 정보 삭제
setAccessToken(null);
if (typeof window !== 'undefined') {
localStorage.removeItem('user');
}
}
},
/**
* 토큰 검증 및 사용자 정보 가져오기
*/
async verifyToken(): Promise<User | null> {
try {
const response = await apiClient.get<{ user: User }>('/api/auth/verify/');
return response.data.user;
} catch (error) {
return null;
}
},
/**
* 현재 Access Token 가져오기
*/
getAccessToken(): string | null {
return getAccessToken();
},
/**
* 토큰 변조 검증
*/
async checkToken(token: string): Promise<boolean> {
try {
const response = await apiClient.post('/api/auth/check/', { token });
return response.data.valid;
} catch (error) {
return false;
}
},
};
3. Auth Context & Hook 구현
// contexts/AuthContext.tsx
'use client';
import React, { createContext, useContext, useState, useEffect } from 'react';
import { authService, User } from '@/lib/api/auth';
interface AuthContextType {
user: User | null;
isLoading: boolean;
isAuthenticated: boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
refreshUser: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
// 초기 인증 상태 확인
useEffect(() => {
const initAuth = async () => {
// 로컬스토리지에서 사용자 정보 로드
const storedUser = localStorage.getItem('user');
if (storedUser) {
setUser(JSON.parse(storedUser));
}
// 토큰 검증
try {
const verifiedUser = await authService.verifyToken();
if (verifiedUser) {
setUser(verifiedUser);
localStorage.setItem('user', JSON.stringify(verifiedUser));
} else {
// 토큰이 유효하지 않으면 사용자 정보 삭제
setUser(null);
localStorage.removeItem('user');
}
} catch (error) {
setUser(null);
localStorage.removeItem('user');
} finally {
setIsLoading(false);
}
};
initAuth();
}, []);
const login = async (username: string, password: string) => {
try {
const response = await authService.login({ username, password });
setUser(response.user);
} catch (error) {
throw error;
}
};
const logout = async () => {
try {
await authService.logout();
} finally {
setUser(null);
}
};
const refreshUser = async () => {
try {
const verifiedUser = await authService.verifyToken();
if (verifiedUser) {
setUser(verifiedUser);
localStorage.setItem('user', JSON.stringify(verifiedUser));
}
} catch (error) {
console.error('Failed to refresh user:', error);
}
};
return (
<AuthContext.Provider
value={{
user,
isLoading,
isAuthenticated: !!user,
login,
logout,
refreshUser,
}}
>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
4. 로그인 페이지 구현
// app/login/page.tsx
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext';
export default function LoginPage() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const { login } = useAuth();
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
await login(username, password);
router.push('/dashboard');
} catch (err: any) {
setError(err.response?.data?.error || 'Login failed');
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full space-y-8 p-8 bg-white rounded-lg shadow">
<h2 className="text-center text-3xl font-bold">Sign in</h2>
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="bg-red-50 text-red-500 p-3 rounded">
{error}
</div>
)}
<div>
<label htmlFor="username" className="block text-sm font-medium">
Username
</label>
<input
id="username"
type="text"
required
value={username}
onChange={(e) => setUsername(e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium">
Password
</label>
<input
id="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md"
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50"
>
{isLoading ? 'Signing in...' : 'Sign in'}
</button>
</form>
</div>
</div>
);
}
5. Protected Route 구현
// components/ProtectedRoute.tsx
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext';
export default function ProtectedRoute({
children,
}: {
children: React.ReactNode;
}) {
const { isAuthenticated, isLoading } = useAuth();
const router = useRouter();
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.push('/login');
}
}, [isAuthenticated, isLoading, router]);
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-lg">Loading...</div>
</div>
);
}
if (!isAuthenticated) {
return null;
}
return <>{children}</>;
}
🌐 환경별 쿠키 관리 전략
1. 프로덕션 환경
프로덕션에서는 최고 수준의 보안이 필요합니다:
# Django settings.py (Production)
CORS_ALLOWED_ORIGINS = [
'https://yourdomain.com',
]
CORS_ALLOW_CREDENTIALS = True
# 쿠키 보안 설정
SESSION_COOKIE_SECURE = True # HTTPS에서만 전송
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SAMESITE = 'Lax' # 'Strict' 또는 'Lax' 권장
CSRF_COOKIE_SAMESITE = 'Lax'
SESSION_COOKIE_HTTPONLY = True # JavaScript 접근 차단
프로덕션 쿠키 설정 원칙:
Secure=True: HTTPS 필수SameSite=Lax: CSRF 공격 방어 (동일 사이트 요청만 허용)HttpOnly=True: XSS 공격 방어 (JavaScript 접근 차단)Domain: 서브도메인 공유 필요 시.yourdomain.com설정
2. 스테이징 환경
스테이징은 프로덕션과 동일한 설정을 사용하되, 도메인만 다르게:
# Django settings.py (Staging)
CORS_ALLOWED_ORIGINS = [
'https://staging.yourdomain.com',
]
CORS_ALLOW_CREDENTIALS = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SAMESITE = 'Lax'
CSRF_COOKIE_SAMESITE = 'Lax'
3. 로컬 개발 환경
로컬에서는 HTTP를 사용하므로 Secure 옵션을 비활성화:
# Django settings.py (Local)
CORS_ALLOWED_ORIGINS = [
'http://localhost:3000',
'http://127.0.0.1:3000',
]
CORS_ALLOW_CREDENTIALS = True
SESSION_COOKIE_SECURE = False # HTTP 허용
CSRF_COOKIE_SECURE = False
SESSION_COOKIE_SAMESITE = 'Lax'
CSRF_COOKIE_SAMESITE = 'Lax'
4. 환경 변수 관리
# .env.local
NEXT_PUBLIC_ENVIRONMENT=local
NEXT_PUBLIC_API_URL=http://localhost:8000
# .env.staging
NEXT_PUBLIC_ENVIRONMENT=staging
NEXT_PUBLIC_API_URL=https://api.staging.yourdomain.com
# .env.production
NEXT_PUBLIC_ENVIRONMENT=production
NEXT_PUBLIC_API_URL=https://api.yourdomain.com
📱 플랫폼별 토큰 관리 전략
1. 웹 브라우저
Access Token: 메모리 (JavaScript 변수) Refresh Token: HttpOnly 쿠키
// 웹에서의 토큰 저장
let accessToken: string | null = null; // 메모리
// Refresh Token은 서버가 HttpOnly 쿠키로 자동 관리
장점:
- XSS 공격에 안전 (Refresh Token이 JavaScript로 접근 불가)
- 페이지 새로고침 시 Refresh Token 유지
- 간단한 구현
단점:
- 페이지 새로고침 시 Access Token 손실 (재로그인 또는 자동 갱신 필요)
2. 모바일 웹
모바일 웹도 일반 웹과 동일하게 처리하되, Safari의 ITP(Intelligent Tracking Prevention) 고려:
// iOS Safari에서 쿠키 사용 시 주의사항
// - SameSite=None은 Secure와 함께 사용 필수
// - 7일 이상 사이트 방문이 없으면 쿠키 삭제됨
// 대안: localStorage + 서버 검증
const saveTokens = (access: string, refresh: string) => {
// Access Token은 메모리
setAccessToken(access);
// Refresh Token은 localStorage (암호화 권장)
if (typeof window !== 'undefined') {
localStorage.setItem('rt', encryptToken(refresh));
}
};
3. 네이티브 앱 (React Native)
네이티브 앱에서는 쿠키 대신 안전한 저장소 사용:
// React Native에서의 토큰 저장
import * as SecureStore from 'expo-secure-store';
// Access Token: 메모리
let accessToken: string | null = null;
// Refresh Token: Secure Storage
export const saveRefreshToken = async (token: string) => {
await SecureStore.setItemAsync('refresh_token', token);
};
export const getRefreshToken = async () => {
return await SecureStore.getItemAsync('refresh_token');
};
export const deleteRefreshToken = async () => {
await SecureStore.deleteItemAsync('refresh_token');
};
// 로그인 시
const login = async (username: string, password: string) => {
const response = await api.post('/api/auth/login/', {
username,
password,
});
// Access Token은 메모리에
accessToken = response.data.access;
// Refresh Token은 Secure Storage에
await saveRefreshToken(response.data.refresh);
};
// 앱 시작 시 토큰 복원
const initAuth = async () => {
const refreshToken = await getRefreshToken();
if (refreshToken) {
try {
// Refresh Token으로 새 Access Token 발급
const response = await api.post('/api/auth/refresh/', {
refresh: refreshToken,
});
accessToken = response.data.access;
// Rotation이 적용된 경우 새 Refresh Token 저장
if (response.data.refresh) {
await saveRefreshToken(response.data.refresh);
}
} catch (error) {
// Refresh Token이 만료되면 로그인 페이지로
await deleteRefreshToken();
}
}
};
네이티브 앱용 Django API 수정:
# accounts/views.py
@api_view(['POST'])
@permission_classes([AllowAny])
def mobile_login(request):
"""모바일 앱용 로그인 API - 쿠키 대신 Response Body로 토큰 반환"""
username = request.data.get('username')
password = request.data.get('password')
user = authenticate(username=username, password=password)
if user is None:
return Response(
{'error': 'Invalid credentials'},
status=status.HTTP_401_UNAUTHORIZED
)
refresh = RefreshToken.for_user(user)
# 모바일에서는 쿠키 대신 Response Body에 모든 토큰 포함
return Response({
'access': str(refresh.access_token),
'refresh': str(refresh), # Refresh Token도 Body에 포함
'user': {
'id': user.id,
'username': user.username,
'email': user.email,
}
})
@api_view(['POST'])
@permission_classes([AllowAny])
def mobile_refresh(request):
"""모바일 앱용 토큰 갱신 API"""
refresh_token = request.data.get('refresh')
if not refresh_token:
return Response(
{'error': 'Refresh token required'},
status=status.HTTP_400_BAD_REQUEST
)
try:
refresh = RefreshToken(refresh_token)
response_data = {
'access': str(refresh.access_token),
}
# Rotation 적용 시 새 Refresh Token도 반환
if settings.SIMPLE_JWT.get('ROTATE_REFRESH_TOKENS'):
new_refresh = RefreshToken.for_user(refresh.user)
response_data['refresh'] = str(new_refresh)
# 기존 토큰 블랙리스트 추가
if settings.SIMPLE_JWT.get('BLACKLIST_AFTER_ROTATION'):
try:
refresh.blacklist()
except AttributeError:
pass
return Response(response_data)
except TokenError:
return Response(
{'error': 'Invalid or expired refresh token'},
status=status.HTTP_401_UNAUTHORIZED
)
🔄 Access Token 만료 처리
1. 자동 갱신 전략
전략 A: 만료 시점에 갱신 (Lazy Refresh)
클라이언트가 401 에러를 받으면 자동으로 갱신:
// 이미 구현된 Axios Interceptor 사용
// 401 에러 발생 → 자동으로 /api/auth/refresh/ 호출
// 성공 시 실패한 요청 재시도
장점:
- 구현이 간단
- 불필요한 갱신 요청 없음
단점:
- 첫 번째 요청이 항상 실패
- 사용자 경험이 약간 저하될 수 있음
전략 B: 만료 전 갱신 (Proactive Refresh)
토큰이 만료되기 전에 미리 갱신:
// lib/api/tokenManager.ts
import { jwtDecode } from 'jwt-decode';
import { authService } from './auth';
interface JWTPayload {
exp: number;
user_id: number;
}
export const checkAndRefreshToken = async () => {
const token = authService.getAccessToken();
if (!token) return;
try {
const decoded = jwtDecode<JWTPayload>(token);
const currentTime = Date.now() / 1000;
const timeUntilExpiry = decoded.exp - currentTime;
// 5분 이하로 남았으면 갱신
if (timeUntilExpiry < 300) {
console.log('Token expiring soon, refreshing...');
await apiClient.post('/api/auth/refresh/');
}
} catch (error) {
console.error('Token check failed:', error);
}
};
// 주기적으로 체크 (1분마다)
if (typeof window !== 'undefined') {
setInterval(checkAndRefreshToken, 60000);
}
장점:
- 사용자 경험 향상 (요청 실패 없음)
- 토큰 만료로 인한 서비스 중단 최소화
단점:
- 추가적인 백그라운드 요청 발생
- 구현이 복잡
2. 백그라운드 자동 갱신 (권장)
서버의 미들웨어와 클라이언트의 Interceptor를 조합:
- 서버: 토큰 만료 5분 전부터 Response 헤더에 새 토큰 포함
- 클라이언트: Response Interceptor에서 자동으로 토큰 업데이트
// Response Interceptor (이미 구현됨)
apiClient.interceptors.response.use(
(response) => {
// X-New-Access-Token 헤더가 있으면 자동 업데이트
const newAccessToken = response.headers['x-new-access-token'];
if (newAccessToken) {
setAccessToken(newAccessToken);
}
return response;
},
// ...
);
이 방식은 사용자가 API를 호출하는 동안 토큰이 자동으로 갱신되어 추가 요청 없이 seamless한 경험을 제공합니다.
🛡️ Access Token 변조 대응
1. 토큰 변조 탐지 메커니즘
JWT는 서명(Signature)을 통해 변조를 탐지합니다:
# JWT 구조
# Header.Payload.Signature
# Signature 생성 과정
signature = HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret_key
)
# 변조 탐지
# 1. Payload나 Header가 변경되면
# 2. 다시 계산한 Signature가 기존 Signature와 일치하지 않음
# 3. 토큰이 유효하지 않다고 판단
2. Django에서 변조된 토큰 처리
Django REST Framework Simple JWT는 자동으로 변조를 탐지합니다:
# rest_framework_simplejwt의 내부 동작
try:
# 토큰 디코딩 및 검증
token = AccessToken(token_string)
# 서명 검증, 만료 시간 확인, 발급자 확인 등
except TokenError as e:
# 변조된 토큰 또는 만료된 토큰
# - InvalidToken: 서명이 일치하지 않음 (변조됨)
# - TokenBackendError: 디코딩 실패
# - TokenExpired: 만료됨
raise AuthenticationFailed('Token is invalid')
3. 변조 탐지 및 로깅
# accounts/authentication.py
from rest_framework_simplejwt.authentication import JWTAuthentication
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
from rest_framework.exceptions import AuthenticationFailed
import logging
logger = logging.getLogger(__name__)
class CustomJWTAuthentication(JWTAuthentication):
"""커스텀 JWT 인증 - 변조 탐지 강화"""
def authenticate(self, request):
try:
return super().authenticate(request)
except InvalidToken as e:
# 변조된 토큰 로깅
token = self.get_raw_token(
self.get_header(request)
)
logger.warning(
f"Invalid token detected from IP {request.META.get('REMOTE_ADDR')}: {str(e)}"
)
# 반복적인 변조 시도 감지
self.log_suspicious_activity(request)
raise AuthenticationFailed('Token is invalid or has been tampered with')
except TokenError as e:
logger.info(f"Token error: {str(e)}")
raise AuthenticationFailed('Token is invalid')
def log_suspicious_activity(self, request):
"""의심스러운 활동 로깅 (Rate Limiting, IP 차단 등과 연동)"""
ip_address = request.META.get('REMOTE_ADDR')
user_agent = request.META.get('HTTP_USER_AGENT')
# Redis 또는 DB에 기록하여 일정 횟수 이상 시도 시 차단
from django.core.cache import cache
cache_key = f'suspicious_token_attempts:{ip_address}'
attempts = cache.get(cache_key, 0)
attempts += 1
cache.set(cache_key, attempts, 3600) # 1시간 동안 유지
if attempts > 5:
logger.error(
f"Multiple invalid token attempts from {ip_address}. "
f"User-Agent: {user_agent}"
)
# 알림 전송, IP 차단 등의 추가 조치
settings.py에서 커스텀 인증 클래스 사용:
# settings.py
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'accounts.authentication.CustomJWTAuthentication',
),
}
4. 클라이언트 측 변조 처리
// lib/api/client.ts
apiClient.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
if (error.response?.status === 401) {
const errorData = error.response.data as any;
// 토큰 변조가 감지된 경우
if (errorData?.error?.includes('tampered') ||
errorData?.error?.includes('invalid')) {
console.error('Token tampering detected!');
// 모든 토큰 삭제
setAccessToken(null);
localStorage.removeItem('user');
// 강제 로그아웃 및 로그인 페이지로 이동
alert('Security issue detected. Please login again.');
window.location.href = '/login';
return Promise.reject(error);
}
}
// 기타 에러 처리...
return Promise.reject(error);
}
);
5. 추가 보안 강화 방법
A. JWT ID (jti) 사용
각 토큰에 고유 ID를 부여하여 일회성 사용 강제:
# settings.py
SIMPLE_JWT = {
# ...
'JTI_CLAIM': 'jti', # JWT ID 클레임 활성화
}
# models.py
from django.db import models
class UsedToken(models.Model):
"""사용된 토큰을 추적하는 모델"""
jti = models.CharField(max_length=255, unique=True)
user = models.ForeignKey('auth.User', on_delete=models.CASCADE)
used_at = models.DateTimeField(auto_now_add=True)
class Meta:
indexes = [
models.Index(fields=['jti']),
]
# authentication.py
class JTIValidationMixin:
"""JTI 검증 믹스인"""
def validate_jti(self, token):
jti = token.get('jti')
# 이미 사용된 토큰인지 확인
if UsedToken.objects.filter(jti=jti).exists():
raise InvalidToken('Token has already been used')
# 토큰 사용 기록
UsedToken.objects.create(
jti=jti,
user_id=token.get('user_id')
)
B. 클라이언트 Fingerprinting
토큰에 클라이언트 정보를 포함하여 토큰 탈취 방지:
# accounts/views.py
import hashlib
def generate_fingerprint(request):
"""클라이언트 fingerprint 생성"""
user_agent = request.META.get('HTTP_USER_AGENT', '')
accept_language = request.META.get('HTTP_ACCEPT_LANGUAGE', '')
accept_encoding = request.META.get('HTTP_ACCEPT_ENCODING', '')
fingerprint_string = f"{user_agent}{accept_language}{accept_encoding}"
return hashlib.sha256(fingerprint_string.encode()).hexdigest()
@api_view(['POST'])
@permission_classes([AllowAny])
def login_with_fingerprint(request):
"""Fingerprint를 포함한 로그인"""
# ... 기존 인증 로직
user = authenticate(username=username, password=password)
# Fingerprint 생성
fingerprint = generate_fingerprint(request)
# 커스텀 토큰 생성
refresh = RefreshToken.for_user(user)
refresh['fingerprint'] = fingerprint # Payload에 fingerprint 추가
# ... 나머지 로직
# 인증 시 fingerprint 검증
class FingerprintJWTAuthentication(JWTAuthentication):
def authenticate(self, request):
validated_token = self.get_validated_token(
self.get_raw_token(self.get_header(request))
)
# Fingerprint 검증
token_fingerprint = validated_token.get('fingerprint')
current_fingerprint = generate_fingerprint(request)
if token_fingerprint != current_fingerprint:
logger.warning(
f"Fingerprint mismatch! Token may have been stolen. "
f"User: {validated_token.get('user_id')}"
)
raise AuthenticationFailed('Token fingerprint mismatch')
return self.get_user(validated_token), validated_token
C. IP 화이트리스트 (엔터프라이즈용)
특정 IP 대역에서만 토큰 사용 허용:
# accounts/authentication.py
class IPRestrictedJWTAuthentication(JWTAuthentication):
"""IP 주소 제한을 포함한 JWT 인증"""
ALLOWED_IP_RANGES = [
# 사무실 IP 대역
'203.0.113.0/24',
# VPN IP 대역
'198.51.100.0/24',
]
def authenticate(self, request):
# 토큰 검증
user, validated_token = super().authenticate(request)
# IP 주소 확인
client_ip = self.get_client_ip(request)
if not self.is_ip_allowed(client_ip):
logger.warning(
f"Access denied from unauthorized IP: {client_ip} "
f"for user {user.username}"
)
raise AuthenticationFailed('Access denied from this IP address')
return user, validated_token
def get_client_ip(self, request):
"""클라이언트 IP 주소 가져오기 (프록시 고려)"""
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
ip = x_forwarded_for.split(',')[0]
else:
ip = request.META.get('REMOTE_ADDR')
return ip
def is_ip_allowed(self, ip):
"""IP 주소가 허용된 범위에 있는지 확인"""
from ipaddress import ip_address, ip_network
client_ip = ip_address(ip)
for ip_range in self.ALLOWED_IP_RANGES:
if client_ip in ip_network(ip_range):
return True
return False
🔐 보안 Best Practices
1. 토큰 수명 설정 지침
# 권장 설정
SIMPLE_JWT = {
# 일반 서비스
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=15), # 15분
'REFRESH_TOKEN_LIFETIME': timedelta(days=7), # 7일
# 고보안 서비스 (금융, 의료 등)
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=5), # 5분
'REFRESH_TOKEN_LIFETIME': timedelta(hours=24), # 24시간
# 낮은 보안 요구사항 (공개 콘텐츠 등)
'ACCESS_TOKEN_LIFETIME': timedelta(hours=1), # 1시간
'REFRESH_TOKEN_LIFETIME': timedelta(days=30), # 30일
}
2. HTTPS 필수
프로덕션 환경에서는 반드시 HTTPS 사용:
# settings.py (Production)
SECURE_SSL_REDIRECT = True # HTTP를 HTTPS로 자동 리다이렉트
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
SECURE_HSTS_SECONDS = 31536000 # HSTS 활성화 (1년)
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
3. 시크릿 키 관리
JWT 서명에 사용되는 시크릿 키는 환경변수로 관리:
# .env
JWT_SECRET_KEY=your-super-secret-key-at-least-32-characters-long
# settings.py
import os
from dotenv import load_dotenv
load_dotenv()
SIMPLE_JWT = {
'SIGNING_KEY': os.environ.get('JWT_SECRET_KEY'),
}
# 시크릿 키가 설정되지 않았으면 에러
if not os.environ.get('JWT_SECRET_KEY'):
raise ValueError('JWT_SECRET_KEY environment variable is not set')
4. Rate Limiting
무차별 대입 공격 방어:
# settings.py
REST_FRAMEWORK = {
'DEFAULT_THROTTLE_CLASSES': [
'rest_framework.throttling.AnonRateThrottle',
'rest_framework.throttling.UserRateThrottle'
],
'DEFAULT_THROTTLE_RATES': {
'anon': '100/hour', # 익명 사용자: 시간당 100회
'user': '1000/hour', # 인증된 사용자: 시간당 1000회
'login': '5/minute', # 로그인: 분당 5회
}
}
# accounts/views.py
from rest_framework.throttling import AnonRateThrottle
class LoginRateThrottle(AnonRateThrottle):
rate = '5/minute'
@api_view(['POST'])
@permission_classes([AllowAny])
@throttle_classes([LoginRateThrottle])
def login(request):
# 로그인 로직
pass
5. 로깅 및 모니터링
보안 이벤트를 체계적으로 로깅:
# settings.py
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'verbose': {
'format': '{levelname} {asctime} {module} {message}',
'style': '{',
},
},
'handlers': {
'file': {
'level': 'INFO',
'class': 'logging.handlers.RotatingFileHandler',
'filename': 'logs/security.log',
'maxBytes': 1024 * 1024 * 10, # 10MB
'backupCount': 5,
'formatter': 'verbose',
},
'console': {
'level': 'INFO',
'class': 'logging.StreamHandler',
'formatter': 'verbose',
},
},
'loggers': {
'accounts': { # accounts 앱의 로거
'handlers': ['file', 'console'],
'level': 'INFO',
'propagate': False,
},
},
}
# accounts/views.py
logger.info(f"Login attempt for user: {username} from IP: {request.META.get('REMOTE_ADDR')}")
logger.warning(f"Failed login attempt for user: {username}")
logger.error(f"Multiple failed login attempts from IP: {ip_address}")
🚀 고급 패턴: Sliding Sessions
사용자가 활동하는 동안 자동으로 세션을 연장하는 “Sliding Session” 패턴:
# accounts/middleware.py
from datetime import datetime, timezone, timedelta
from django.conf import settings
from rest_framework_simplejwt.tokens import RefreshToken
class SlidingSessionMiddleware(MiddlewareMixin):
"""
사용자 활동 시 자동으로 토큰 수명 연장
- 마지막 활동 후 일정 시간 경과 시 갱신
"""
def process_response(self, request, response):
# 인증된 요청인지 확인
if not hasattr(request, 'user') or not request.user.is_authenticated:
return response
# 마지막 활동 시간 확인
last_activity = request.session.get('last_activity')
now = datetime.now(timezone.utc)
should_refresh = False
if last_activity:
last_activity_dt = datetime.fromisoformat(last_activity)
time_since_activity = (now - last_activity_dt).total_seconds()
# 10분 이상 경과 시 갱신
if time_since_activity > 600:
should_refresh = True
else:
should_refresh = True
if should_refresh:
# 새로운 토큰 발급
refresh_token = request.COOKIES.get('refresh_token')
if refresh_token:
try:
refresh = RefreshToken(refresh_token)
new_access = str(refresh.access_token)
# 새 토큰을 헤더에 추가
response['X-New-Access-Token'] = new_access
# 마지막 활동 시간 업데이트
request.session['last_activity'] = now.isoformat()
except:
pass
return response
📊 성능 최적화
1. 토큰 캐싱
자주 검증되는 토큰을 Redis에 캐싱:
# accounts/authentication.py
from django.core.cache import cache
import hashlib
class CachedJWTAuthentication(JWTAuthentication):
"""토큰 검증 결과를 캐싱하는 인증 클래스"""
CACHE_TTL = 300 # 5분
def get_validated_token(self, raw_token):
# 토큰 해시 생성
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
cache_key = f'jwt_validated:{token_hash}'
# 캐시에서 검증 결과 확인
cached_result = cache.get(cache_key)
if cached_result:
return cached_result
# 검증 수행
validated_token = super().get_validated_token(raw_token)
# 결과 캐싱
cache.set(cache_key, validated_token, self.CACHE_TTL)
return validated_token
2. 비동기 처리
로깅이나 알림을 비동기로 처리하여 응답 시간 최소화:
# accounts/tasks.py
from celery import shared_task
@shared_task
def log_suspicious_activity(ip_address, user_agent, attempts):
"""의심스러운 활동을 비동기로 로깅"""
# DB에 기록
# 알림 전송
# 관리자에게 이메일 발송
pass
# accounts/authentication.py
def log_suspicious_activity(self, request):
# 비동기로 처리
log_suspicious_activity.delay(
ip_address=request.META.get('REMOTE_ADDR'),
user_agent=request.META.get('HTTP_USER_AGENT'),
attempts=attempts
)
🧪 테스트 코드
JWT 인증 시스템의 테스트:
# accounts/tests.py
from django.test import TestCase
from django.contrib.auth.models import User
from rest_framework.test import APIClient
from rest_framework import status
class JWTAuthenticationTests(TestCase):
def setUp(self):
self.client = APIClient()
self.user = User.objects.create_user(
username='testuser',
password='testpass123'
)
def test_login_success(self):
"""정상 로그인 테스트"""
response = self.client.post('/api/auth/login/', {
'username': 'testuser',
'password': 'testpass123',
})
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn('access', response.data)
self.assertIn('refresh_token', response.cookies)
def test_login_invalid_credentials(self):
"""잘못된 인증 정보로 로그인 시도"""
response = self.client.post('/api/auth/login/', {
'username': 'testuser',
'password': 'wrongpassword',
})
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_access_protected_endpoint(self):
"""보호된 엔드포인트 접근 테스트"""
# 로그인
login_response = self.client.post('/api/auth/login/', {
'username': 'testuser',
'password': 'testpass123',
})
access_token = login_response.data['access']
# 토큰으로 보호된 엔드포인트 접근
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {access_token}')
response = self.client.get('/api/auth/verify/')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['user']['username'], 'testuser')
def test_token_refresh(self):
"""토큰 갱신 테스트"""
# 로그인
login_response = self.client.post('/api/auth/login/', {
'username': 'testuser',
'password': 'testpass123',
})
# Refresh Token으로 새 Access Token 발급
refresh_response = self.client.post('/api/auth/refresh/')
self.assertEqual(refresh_response.status_code, status.HTTP_200_OK)
self.assertIn('access', refresh_response.data)
def test_tampered_token(self):
"""변조된 토큰 테스트"""
# 정상 로그인
login_response = self.client.post('/api/auth/login/', {
'username': 'testuser',
'password': 'testpass123',
})
access_token = login_response.data['access']
# 토큰 변조 (마지막 문자 변경)
tampered_token = access_token[:-1] + 'X'
# 변조된 토큰으로 접근 시도
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {tampered_token}')
response = self.client.get('/api/auth/verify/')
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_logout(self):
"""로그아웃 테스트"""
# 로그인
login_response = self.client.post('/api/auth/login/', {
'username': 'testuser',
'password': 'testpass123',
})
access_token = login_response.data['access']
# 로그아웃
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {access_token}')
logout_response = self.client.post('/api/auth/logout/')
self.assertEqual(logout_response.status_code, status.HTTP_200_OK)
# 로그아웃 후 Refresh Token 사용 불가
refresh_response = self.client.post('/api/auth/refresh/')
self.assertEqual(refresh_response.status_code, status.HTTP_401_UNAUTHORIZED)
📝 완전한 구현 체크리스트
Django 백엔드
- djangorestframework-simplejwt 설치 및 설정
- Access Token 수명 설정 (15분 권장)
- Refresh Token 수명 설정 (7일 권장)
- Refresh Token Rotation 활성화
- 환경별 CORS 설정 (로컬/스테이징/프로덕션)
- HttpOnly 쿠키로 Refresh Token 저장
- 로그인/로그아웃/갱신 API 구현
- 토큰 변조 탐지 및 로깅
- Rate Limiting 적용
- HTTPS 강제 (프로덕션)
- 시크릿 키 환경변수 관리
- 보안 로깅 설정
- 테스트 코드 작성
Next.js 프론트엔드
- Axios 인스턴스 생성 및 환경별 baseURL 설정
- withCredentials 활성화 (쿠키 전송)
- Access Token 메모리 저장
- Request Interceptor로 토큰 자동 추가
- Response Interceptor로 401 에러 자동 처리
- 토큰 갱신 로직 구현
- Auth Context/Provider 구현
- Protected Route 구현
- 로그인/로그아웃 페이지 구현
- 토큰 만료 알림 UI
- 네트워크 에러 처리
모바일 앱 (React Native)
- Secure Storage 라이브러리 설치
- Refresh Token을 Secure Storage에 저장
- Access Token은 메모리에 저장
- 앱 시작 시 토큰 복원 로직
- 모바일용 로그인/갱신 API 엔드포인트
- 네이티브 인증 (생체인증) 통합 (선택)
보안
- HTTPS 적용 (프로덕션)
- Secure, HttpOnly, SameSite 쿠키 플래그 설정
- JWT 서명 알고리즘 적절히 선택 (HS256 또는 RS256)
- 토큰 블랙리스트 구현
- Rate Limiting
- 의심스러운 활동 로깅 및 알림
- IP 화이트리스트 (필요 시)
- Fingerprinting (필요 시)
- 정기적인 시크릿 키 로테이션
🎓 결론
Django와 Next.js를 활용한 JWT 인증 시스템은 현대적이고 확장 가능한 인증 솔루션입니다. 이 가이드에서 다룬 내용을 정리하면:
핵심 포인트
- Access Token은 짧게, Refresh Token은 길게
- Access Token: 15분 (API 요청용)
- Refresh Token: 7일 (갱신 전용)
- Refresh Token은 HttpOnly 쿠키에 저장
- XSS 공격 방어
- 자동으로 서버에 전송
- Access Token은 메모리에 저장
- JavaScript 변수로 관리
- 페이지 새로고침 시 자동 갱신
- 환경별 쿠키 설정
- 프로덕션: Secure=True, SameSite=Lax
- 로컬: Secure=False
- 플랫폼별 전략
- 웹: HttpOnly 쿠키
- 앱: Secure Storage
- 자동 갱신
- Response Interceptor 활용
- 사용자 경험 향상
- 변조 탐지
- JWT 서명 검증
- 로깅 및 알림
- Rate Limiting
이 가이드의 모든 코드는 실제 프로덕션 환경에서 검증된 패턴을 기반으로 작성되었습니다. 프로젝트의 보안 요구사항과 규모에 맞게 조정하여 사용하시기 바랍니다.
다음 단계
- OAuth 2.0 통합 (소셜 로그인)
- Multi-Factor Authentication (MFA)
- Single Sign-On (SSO)
- JWT 대안 (Paseto, Macaroons) 검토
- Zero Trust Architecture 적용
JWT 인증은 시작일 뿐입니다. 지속적인 보안 업데이트와 모니터링을 통해 안전한 애플리케이션을 유지하세요! 🔐