Skip to content
Published on

Django 인증 실전 가이드 — 세션, DRF + SimpleJWT, 미들웨어, 쿠키 기반 인증 완전 정복

Authors
  • Name
    Twitter

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

개요 — Django 인증 아키텍처

Django는 django.contrib.auth 모듈을 통해 강력한 인증 프레임워크를 기본 제공한다. 사용자 모델, 권한 시스템, 비밀번호 해싱, 그리고 세션 기반 인증이 별도 설치 없이 동작한다. 그러나 SPA 프론트엔드나 마이크로서비스 아키텍처에서는 세션 기반 인증만으로 부족한 경우가 많아 JWT(JSON Web Token) 기반 인증을 함께 사용해야 한다.

Django의 인증 처리 흐름은 미들웨어 스택에 의해 결정된다.

Request 수신
SecurityMiddlewareHTTPS 리다이렉트, HSTS 설정
SessionMiddleware           ← session_key 쿠키로 세션 로딩 → request.session 바인딩
AuthenticationMiddleware    ← request.session에서 user_id 추출 → request.user 바인딩
View 또는 DRF APIView      ← request.user 사용 가능

DRF(Django REST Framework)를 사용하는 경우, 인증은 DEFAULT_AUTHENTICATION_CLASSES에 설정된 인증 클래스가 담당한다. SessionAuthentication, TokenAuthentication, JWTAuthentication 등을 조합하여 사용할 수 있다.

# settings.py
INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'rest_framework',
    'rest_framework_simplejwt',
    'rest_framework_simplejwt.token_blacklist',
    'corsheaders',
]

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'corsheaders.middleware.CorsMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

세션 기반 인증

Django의 세션 인증은 서버 측에 세션 데이터를 저장하고, 클라이언트에 sessionid 쿠키를 발급하는 전통적인 방식이다.

세션 백엔드 종류

# settings.py — 세션 백엔드 설정

# 1. DB 기반 (기본값)
SESSION_ENGINE = 'django.contrib.sessions.backends.db'

# 2. 캐시 기반 (Redis/Memcached)
SESSION_ENGINE = 'django.contrib.sessions.backends.cache'
SESSION_CACHE_ALIAS = 'default'

# 3. 캐시 + DB 혼합 (쓰기는 DB, 읽기는 캐시 우선)
SESSION_ENGINE = 'django.contrib.sessions.backends.cached_db'

# 4. 쿠키 기반 (서버 저장 없음 — 서명된 쿠키에 세션 데이터 포함)
SESSION_ENGINE = 'django.contrib.sessions.backends.signed_cookies'

# 공통 설정
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SECURE = True        # HTTPS 전용
SESSION_COOKIE_SAMESITE = 'Lax'
SESSION_COOKIE_AGE = 1209600        # 2주 (초 단위)
SESSION_COOKIE_DOMAIN = '.example.com'  # 서브도메인 공유

로그인/로그아웃 구현

# views.py
from django.contrib.auth import authenticate, login, logout
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
import json


@require_POST
def session_login_view(request):
    """세션 기반 로그인"""
    data = json.loads(request.body)
    username = data.get('username')
    password = data.get('password')

    user = authenticate(request, username=username, password=password)

    if user is not None:
        login(request, user)  # 세션에 user_id 저장 + sessionid 쿠키 발급
        return JsonResponse({
            'message': '로그인 성공',
            'user': {
                'id': user.id,
                'username': user.username,
                'email': user.email,
            }
        })
    return JsonResponse({'error': '인증 실패'}, status=401)


@require_POST
def session_logout_view(request):
    """세션 기반 로그아웃"""
    logout(request)  # 세션 데이터 삭제 + sessionid 쿠키 무효화
    return JsonResponse({'message': '로그아웃 완료'})


def profile_view(request):
    """request.user 접근"""
    if request.user.is_authenticated:
        return JsonResponse({
            'id': request.user.id,
            'username': request.user.username,
            'email': request.user.email,
            'is_staff': request.user.is_staff,
        })
    return JsonResponse({'error': '인증되지 않은 사용자'}, status=401)

authenticate() 함수는 AUTHENTICATION_BACKENDS에 등록된 백엔드를 순회하며 자격 증명을 검증한다. login() 함수는 세션에 _auth_user_id, _auth_user_backend, _auth_user_hash를 저장하고 sessionid 쿠키를 응답에 설정한다.

JWT 기반 인증 (DRF + SimpleJWT)

SPA 프론트엔드, 모바일 앱, 마이크로서비스 간 통신에서는 Stateless한 JWT 인증이 더 적합하다. Django에서는 djangorestframework-simplejwt 라이브러리가 사실상 표준이다.

SimpleJWT 설정

pip install djangorestframework-simplejwt
# settings.py
from datetime import timedelta

REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': (
        'rest_framework_simplejwt.authentication.JWTAuthentication',
        'rest_framework.authentication.SessionAuthentication',
    ),
    'DEFAULT_PERMISSION_CLASSES': (
        'rest_framework.permissions.IsAuthenticated',
    ),
}

SIMPLE_JWT = {
    # 토큰 수명
    'ACCESS_TOKEN_LIFETIME': timedelta(minutes=15),
    'REFRESH_TOKEN_LIFETIME': timedelta(days=7),

    # 리프레시 토큰 순환 — True면 refresh할 때마다 새 refresh 토큰 발급
    'ROTATE_REFRESH_TOKENS': True,
    'BLACKLIST_AFTER_ROTATION': True,

    # 알고리즘 및 키
    'ALGORITHM': 'HS256',
    'SIGNING_KEY': SECRET_KEY,
    # RS256 사용 시:
    # 'ALGORITHM': 'RS256',
    # 'SIGNING_KEY': open('/path/to/private.pem').read(),
    # 'VERIFYING_KEY': open('/path/to/public.pem').read(),

    # 헤더
    'AUTH_HEADER_TYPES': ('Bearer',),
    'AUTH_HEADER_NAME': 'HTTP_AUTHORIZATION',

    # 사용자 식별자
    'USER_ID_FIELD': 'id',
    'USER_ID_CLAIM': 'user_id',

    # 토큰 타입
    'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',),
    'TOKEN_TYPE_CLAIM': 'token_type',

    # JTI (JWT ID) — 토큰 고유 식별자
    'JTI_CLAIM': 'jti',

    # 슬라이딩 토큰 (선택 사항)
    'SLIDING_TOKEN_REFRESH_EXP_CLAIM': 'refresh_exp',
    'SLIDING_TOKEN_LIFETIME': timedelta(minutes=5),
    'SLIDING_TOKEN_REFRESH_LIFETIME': timedelta(days=1),
}

URL 설정

# urls.py
from django.urls import path
from rest_framework_simplejwt.views import (
    TokenObtainPairView,
    TokenRefreshView,
    TokenVerifyView,
)

urlpatterns = [
    path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
    path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
    path('api/token/verify/', TokenVerifyView.as_view(), name='token_verify'),
]

POST /api/token/usernamepassword를 전송하면 accessrefresh 토큰이 JSON으로 반환된다.

커스텀 클레임 추가

기본 JWT payload에는 user_id, token_type, exp, iat, jti만 포함된다. 사용자 역할, 테넌트 ID 등 추가 정보를 포함하려면 시리얼라이저를 커스터마이징한다.

# serializers.py
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
from rest_framework_simplejwt.views import TokenObtainPairView


class CustomTokenObtainPairSerializer(TokenObtainPairSerializer):
    @classmethod
    def get_token(cls, user):
        token = super().get_token(user)

        # 커스텀 클레임 추가
        token['username'] = user.username
        token['email'] = user.email
        token['is_staff'] = user.is_staff

        # 사용자 역할 (ManyToMany 관계 가정)
        token['roles'] = list(user.groups.values_list('name', flat=True))

        # 멀티테넌트 환경 — 사용자 프로필에서 tenant_id 가져오기
        if hasattr(user, 'profile'):
            token['tenant_id'] = str(user.profile.tenant_id)
            token['organization'] = user.profile.organization_name

        return token

    def validate(self, attrs):
        data = super().validate(attrs)
        # 응답 JSON에 추가 정보 포함
        data['user'] = {
            'id': self.user.id,
            'username': self.user.username,
            'email': self.user.email,
            'roles': list(self.user.groups.values_list('name', flat=True)),
        }
        return data


class CustomTokenObtainPairView(TokenObtainPairView):
    serializer_class = CustomTokenObtainPairSerializer

생성되는 JWT payload 예시:

{
  "token_type": "access",
  "exp": 1741500000,
  "iat": 1741499100,
  "jti": "a1b2c3d4e5f6...",
  "user_id": 42,
  "username": "youngju",
  "email": "youngju@example.com",
  "is_staff": false,
  "roles": ["editor", "reviewer"],
  "tenant_id": "550e8400-e29b-41d4-a716-446655440000"
}

쿠키에 JWT 저장하기

SPA 환경에서 JWT를 localStorage에 저장하면 XSS 공격에 취약하다. 보안을 강화하려면 JWT를 HttpOnly 쿠키에 저장하여 JavaScript에서 접근할 수 없도록 해야 한다.

쿠키 기반 로그인 뷰

# views.py
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework import status
from django.contrib.auth import authenticate
from rest_framework_simplejwt.tokens import RefreshToken
from django.conf import settings


@api_view(['POST'])
@permission_classes([AllowAny])
def cookie_login_view(request):
    """JWT를 HttpOnly 쿠키에 저장하는 로그인 뷰"""
    username = request.data.get('username')
    password = request.data.get('password')

    user = authenticate(username=username, password=password)
    if user is None:
        return Response(
            {'error': '유효하지 않은 자격 증명입니다.'},
            status=status.HTTP_401_UNAUTHORIZED
        )

    # 토큰 생성
    refresh = RefreshToken.for_user(user)

    # 커스텀 클레임 추가
    refresh['username'] = user.username
    refresh['roles'] = list(user.groups.values_list('name', flat=True))

    access_token = str(refresh.access_token)
    refresh_token = str(refresh)

    response = Response({
        'message': '로그인 성공',
        'user': {
            'id': user.id,
            'username': user.username,
            'email': user.email,
        }
    })

    # Access Token 쿠키 설정
    response.set_cookie(
        key='access_token',
        value=access_token,
        max_age=settings.SIMPLE_JWT['ACCESS_TOKEN_LIFETIME'].total_seconds(),
        httponly=True,       # JavaScript에서 접근 불가
        secure=True,         # HTTPS에서만 전송
        samesite='Lax',      # CSRF 보호 (cross-site POST 차단)
        domain='.example.com',  # 서브도메인 간 공유
        path='/',
    )

    # Refresh Token 쿠키 설정
    response.set_cookie(
        key='refresh_token',
        value=refresh_token,
        max_age=settings.SIMPLE_JWT['REFRESH_TOKEN_LIFETIME'].total_seconds(),
        httponly=True,
        secure=True,
        samesite='Lax',
        domain='.example.com',
        path='/api/token/refresh/',  # refresh 엔드포인트에서만 전송
    )

    return response

쿠키 기반 DRF 인증 클래스

DRF의 기본 JWTAuthenticationAuthorization 헤더에서 토큰을 읽는다. 쿠키에서 읽으려면 커스텀 인증 클래스가 필요하다.

# authentication.py
from rest_framework_simplejwt.authentication import JWTAuthentication
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
from rest_framework_simplejwt.tokens import UntypedToken
from django.conf import settings


class CookieJWTAuthentication(JWTAuthentication):
    """쿠키에서 JWT를 읽는 DRF 인증 클래스"""

    def authenticate(self, request):
        # 1. 쿠키에서 access_token 읽기
        raw_token = request.COOKIES.get('access_token')

        if raw_token is None:
            # 쿠키에 없으면 기본 헤더 방식 시도 (fallback)
            return super().authenticate(request)

        # 2. 토큰 검증
        try:
            validated_token = self.get_validated_token(raw_token)
        except (InvalidToken, TokenError) as e:
            return None  # 인증 실패 — AnonymousUser로 처리

        # 3. 토큰에서 사용자 객체 조회
        user = self.get_user(validated_token)
        return (user, validated_token)
# settings.py에 등록
REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': (
        'myapp.authentication.CookieJWTAuthentication',
        'rest_framework.authentication.SessionAuthentication',
    ),
}

미들웨어에서 인증 처리

DRF의 APIView뿐 아니라 일반 Django View에서도 쿠키 기반 JWT 인증을 사용하려면 미들웨어에서 처리하는 것이 효과적이다.

# middleware.py
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser
from rest_framework_simplejwt.tokens import AccessToken
from rest_framework_simplejwt.exceptions import TokenError, InvalidToken
import logging

User = get_user_model()
logger = logging.getLogger(__name__)


class JWTCookieAuthMiddleware:
    """
    쿠키에서 JWT를 읽어 request.user를 설정하는 미들웨어.
    AuthenticationMiddleware 뒤에 위치시켜야 한다.
    세션 인증으로 이미 인증된 사용자는 건너뛴다.
    """

    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        # 세션으로 이미 인증된 경우 건너뛰기
        if hasattr(request, 'user') and request.user.is_authenticated:
            return self.get_response(request)

        # 쿠키에서 access_token 추출
        access_token = request.COOKIES.get('access_token')

        if access_token:
            try:
                # 토큰 검증
                validated_token = AccessToken(access_token)

                # 사용자 조회
                user_id = validated_token.get('user_id')
                user = User.objects.get(id=user_id)

                # request에 사용자 및 토큰 정보 바인딩
                request.user = user
                request.jwt_token = validated_token
                request.jwt_claims = validated_token.payload

            except (TokenError, InvalidToken) as e:
                logger.warning(f'JWT 인증 실패: {e}')
                request.user = AnonymousUser()
            except User.DoesNotExist:
                logger.warning(f'JWT user_id에 해당하는 사용자 없음: {user_id}')
                request.user = AnonymousUser()

        response = self.get_response(request)
        return response
# settings.py — 미들웨어 등록 순서 중요!
MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'corsheaders.middleware.CorsMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'myapp.middleware.JWTCookieAuthMiddleware',  # AuthenticationMiddleware 뒤에!
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

Claim 파싱 및 사용자 정보 접근

request에서 JWT 정보 접근

미들웨어 또는 인증 클래스를 통해 JWT 인증이 완료되면, View에서 다양한 방식으로 사용자 정보에 접근할 수 있다.

# views.py
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework_simplejwt.tokens import AccessToken
import jwt
from django.conf import settings


@api_view(['GET'])
@permission_classes([IsAuthenticated])
def user_info_view(request):
    """request.user에서 정보 접근"""
    user = request.user
    return Response({
        'id': user.id,
        'username': user.username,
        'email': user.email,
        'is_staff': user.is_staff,
        'groups': list(user.groups.values_list('name', flat=True)),
    })


@api_view(['GET'])
@permission_classes([IsAuthenticated])
def jwt_claims_view(request):
    """JWT claim 직접 디코딩"""

    # 방법 1: 미들웨어에서 바인딩된 claims 사용
    if hasattr(request, 'jwt_claims'):
        claims = request.jwt_claims
        return Response({
            'user_id': claims.get('user_id'),
            'username': claims.get('username'),
            'roles': claims.get('roles', []),
            'tenant_id': claims.get('tenant_id'),
            'exp': claims.get('exp'),
        })

    # 방법 2: DRF auth에서 바인딩된 토큰 사용
    if request.auth:
        return Response({
            'user_id': request.auth.get('user_id'),
            'token_type': request.auth.get('token_type'),
            'exp': request.auth.get('exp'),
        })

    # 방법 3: 쿠키에서 직접 디코딩 (PyJWT 사용)
    raw_token = request.COOKIES.get('access_token')
    if raw_token:
        decoded = jwt.decode(
            raw_token,
            settings.SECRET_KEY,
            algorithms=['HS256'],
        )
        return Response(decoded)

    return Response({'error': 'No token found'}, status=400)

커스텀 Permission 클래스

# permissions.py
from rest_framework.permissions import BasePermission


class HasRole(BasePermission):
    """JWT claim에서 역할을 확인하는 권한 클래스"""

    def __init__(self, required_role=None):
        self.required_role = required_role

    def has_permission(self, request, view):
        if not request.user or not request.user.is_authenticated:
            return False

        # view에 required_role 속성이 정의된 경우 사용
        required = getattr(view, 'required_role', self.required_role)
        if required is None:
            return True

        # JWT claims에서 역할 확인
        if hasattr(request, 'jwt_claims'):
            roles = request.jwt_claims.get('roles', [])
            return required in roles

        # DB에서 역할 확인 (fallback)
        return request.user.groups.filter(name=required).exists()


class IsSameTenant(BasePermission):
    """요청 사용자와 리소스의 tenant_id가 일치하는지 확인"""

    def has_object_permission(self, request, view, obj):
        if not hasattr(request, 'jwt_claims'):
            return False

        user_tenant = request.jwt_claims.get('tenant_id')
        obj_tenant = getattr(obj, 'tenant_id', None)

        return user_tenant is not None and str(user_tenant) == str(obj_tenant)
# views.py — Permission 사용 예시
from rest_framework.views import APIView
from rest_framework.response import Response
from myapp.permissions import HasRole, IsSameTenant


class AdminDashboardView(APIView):
    permission_classes = [HasRole]
    required_role = 'admin'

    def get(self, request):
        return Response({'message': '관리자 대시보드'})


class TenantResourceView(APIView):
    permission_classes = [HasRole, IsSameTenant]
    required_role = 'editor'

    def get(self, request, pk):
        resource = Resource.objects.get(pk=pk)
        self.check_object_permissions(request, resource)
        return Response({'resource': resource.name})

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

JWT를 어디에 저장하느냐에 따라 보안 특성이 달라진다.

저장소JS 접근서버 자동 전송XSS 취약CSRF 취약
localStorageOX (수동으로 헤더에 추가)O — 탈취 가능X
sessionStorageOXO — 탈취 가능X
일반 쿠키OO (same-origin)O — 탈취 가능O
HttpOnly 쿠키XO (same-origin)X — JS 접근 불가O — SameSite로 완화
HttpOnly + SameSite=LaxXO (same-origin GET)X대부분 차단
HttpOnly + SameSite=StrictX동일 사이트만XX

권장: HttpOnly + Secure + SameSite=Lax 조합으로 쿠키에 저장하고, 상태 변경 요청(POST, PUT, DELETE)에는 CSRF 토큰 또는 커스텀 헤더를 추가한다.

CORS 설정 (django-cors-headers)

SPA 프론트엔드가 다른 도메인에서 API를 호출하려면 CORS 설정이 필수다.

pip install django-cors-headers
# settings.py

INSTALLED_APPS = [
    # ...
    'corsheaders',
]

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'corsheaders.middleware.CorsMiddleware',  # CommonMiddleware 전에 위치
    'django.middleware.common.CommonMiddleware',
    # ...
]

# === CORS 설정 ===

# 허용할 오리진 (와일드카드 사용 시 credentials 불가)
CORS_ALLOWED_ORIGINS = [
    'https://app.example.com',
    'https://admin.example.com',
    'http://localhost:3000',  # 개발 환경
]

# 쿠키를 포함한 cross-origin 요청 허용 (withCredentials: true)
CORS_ALLOW_CREDENTIALS = True

# 허용할 헤더
CORS_ALLOW_HEADERS = [
    'accept',
    'accept-encoding',
    'authorization',
    'content-type',
    'dnt',
    'origin',
    'user-agent',
    'x-csrftoken',
    'x-requested-with',
]

# 프리플라이트 응답 캐시 시간
CORS_PREFLIGHT_MAX_AGE = 86400  # 24시간

# === CSRF 설정 ===

# CORS_ALLOWED_ORIGINS와 동일하게 설정
CSRF_TRUSTED_ORIGINS = [
    'https://app.example.com',
    'https://admin.example.com',
]

# CSRF 쿠키 설정
CSRF_COOKIE_HTTPONLY = False  # JS에서 읽어야 하므로 False
CSRF_COOKIE_SECURE = True
CSRF_COOKIE_SAMESITE = 'Lax'
CSRF_COOKIE_DOMAIN = '.example.com'

주의: CORS_ALLOW_ALL_ORIGINS = TrueCORS_ALLOW_CREDENTIALS = True를 동시에 설정하면 보안 위험이 있다. 브라우저는 Access-Control-Allow-Origin: *Access-Control-Allow-Credentials: true 조합을 거부한다.

로그아웃 및 토큰 무효화

JWT는 본질적으로 Stateless하여 서버에서 강제 무효화가 어렵다. SimpleJWT의 토큰 블랙리스트 기능을 사용하면 이 문제를 해결할 수 있다.

블랙리스트 설정

# settings.py
INSTALLED_APPS = [
    # ...
    'rest_framework_simplejwt.token_blacklist',
]

# 마이그레이션 실행 필요
# python manage.py migrate

블랙리스트는 OutstandingTokenBlacklistedToken 두 가지 모델을 생성한다. OutstandingToken은 발급된 리프레시 토큰을, BlacklistedToken은 무효화된 토큰을 저장한다.

로그아웃 뷰

# views.py
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework import status
from rest_framework_simplejwt.tokens import RefreshToken
from rest_framework_simplejwt.exceptions import TokenError


@api_view(['POST'])
@permission_classes([IsAuthenticated])
def cookie_logout_view(request):
    """쿠키 기반 JWT 로그아웃 — 블랙리스트 + 쿠키 삭제"""
    refresh_token = request.COOKIES.get('refresh_token')

    if refresh_token:
        try:
            token = RefreshToken(refresh_token)
            token.blacklist()  # 토큰을 블랙리스트에 추가
        except TokenError:
            pass  # 이미 만료되었거나 무효한 토큰

    response = Response({'message': '로그아웃 완료'}, status=status.HTTP_200_OK)

    # 쿠키 삭제
    response.delete_cookie(
        key='access_token',
        domain='.example.com',
        path='/',
    )
    response.delete_cookie(
        key='refresh_token',
        domain='.example.com',
        path='/api/token/refresh/',
    )

    return response

만료된 토큰 정리 (cron/celery)

# management/commands/flush_expired_tokens.py
# SimpleJWT에서 기본 제공하는 명령어 사용:
# python manage.py flushexpiredtokens

# Celery beat 스케줄 설정
CELERY_BEAT_SCHEDULE = {
    'flush-expired-tokens': {
        'task': 'myapp.tasks.flush_expired_tokens',
        'schedule': 86400,  # 매일
    },
}

# tasks.py
from celery import shared_task
from django.core.management import call_command

@shared_task
def flush_expired_tokens():
    call_command('flushexpiredtokens')

토큰 리프레시 전략

Access Token이 만료되면 Refresh Token으로 새 Access Token을 발급받아야 한다. 쿠키 기반 환경에서는 별도의 리프레시 뷰를 구현한다.

# views.py
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework import status
from rest_framework_simplejwt.tokens import RefreshToken
from rest_framework_simplejwt.exceptions import TokenError
from django.conf import settings


@api_view(['POST'])
@permission_classes([AllowAny])
def cookie_token_refresh_view(request):
    """쿠키에서 refresh_token을 읽어 새 access_token + refresh_token 발급"""
    refresh_token = request.COOKIES.get('refresh_token')

    if not refresh_token:
        return Response(
            {'error': 'Refresh token이 없습니다.'},
            status=status.HTTP_401_UNAUTHORIZED
        )

    try:
        old_refresh = RefreshToken(refresh_token)

        # 새 access token 발급
        new_access = str(old_refresh.access_token)

        # ROTATE_REFRESH_TOKENS=True인 경우 새 refresh token 발급
        if settings.SIMPLE_JWT.get('ROTATE_REFRESH_TOKENS', False):
            # 기존 refresh token 블랙리스트 처리
            if settings.SIMPLE_JWT.get('BLACKLIST_AFTER_ROTATION', False):
                old_refresh.blacklist()

            # 새 refresh token 생성
            new_refresh = RefreshToken.for_user(old_refresh.payload.get('user_id'))
            # 기존 커스텀 클레임 복사
            for key in ['username', 'roles', 'tenant_id']:
                if key in old_refresh.payload:
                    new_refresh[key] = old_refresh.payload[key]
            new_refresh_str = str(new_refresh)
        else:
            new_refresh_str = refresh_token  # 기존 토큰 재사용

        response = Response({'message': '토큰 갱신 성공'})

        # 새 access token 쿠키 설정
        response.set_cookie(
            key='access_token',
            value=new_access,
            max_age=settings.SIMPLE_JWT['ACCESS_TOKEN_LIFETIME'].total_seconds(),
            httponly=True,
            secure=True,
            samesite='Lax',
            domain='.example.com',
            path='/',
        )

        # 새 refresh token 쿠키 설정 (순환한 경우)
        if settings.SIMPLE_JWT.get('ROTATE_REFRESH_TOKENS', False):
            response.set_cookie(
                key='refresh_token',
                value=new_refresh_str,
                max_age=settings.SIMPLE_JWT['REFRESH_TOKEN_LIFETIME'].total_seconds(),
                httponly=True,
                secure=True,
                samesite='Lax',
                domain='.example.com',
                path='/api/token/refresh/',
            )

        return response

    except TokenError as e:
        return Response(
            {'error': f'유효하지 않은 refresh token: {str(e)}'},
            status=status.HTTP_401_UNAUTHORIZED
        )

리프레시 흐름 정리:

1. 클라이언트: API 요청 → 401 Unauthorized (access_token 만료)
2. 클라이언트: POST /api/token/refresh/ (refresh_token 쿠키 자동 전송)
3. 서버: refresh_token 검증 → 새 access_token + refresh_token 발급 → 쿠키 설정
4. 클라이언트: 원래 API 요청 재시도 → 200 OK

보안 트레이드오프

XSS 대응

HttpOnly 쿠키에 JWT를 저장하면 JavaScript에서 토큰에 접근할 수 없어 XSS 공격으로 인한 토큰 탈취를 방지한다. 그러나 XSS 취약점이 있으면 공격자가 사용자를 대신하여 API 요청을 보낼 수 있으므로 입력 검증과 출력 이스케이프는 여전히 필수다.

CSRF 대응

쿠키 기반 인증은 CSRF(Cross-Site Request Forgery) 공격에 취약하다. Django는 CsrfViewMiddleware로 CSRF 보호를 제공하지만, JWT 인증과 함께 사용할 때 충돌이 발생할 수 있다.

# CSRF와 JWT 쿠키를 함께 사용하는 전략

# 전략 1: CSRF 보호 유지 (권장)
# — SessionAuthentication은 CSRF 검증을 강제하므로 제거하거나,
#    CookieJWTAuthentication에서 enforce_csrf() 오버라이드
class CookieJWTAuthentication(JWTAuthentication):
    def authenticate(self, request):
        raw_token = request.COOKIES.get('access_token')
        if raw_token is None:
            return None
        validated_token = self.get_validated_token(raw_token)
        user = self.get_user(validated_token)
        return (user, validated_token)

    def enforce_csrf(self, request):
        """쿠키 기반 JWT에서는 CSRF 검증 비활성화
        (SameSite 쿠키가 이미 CSRF를 방어하므로)"""
        return  # CSRF 검증 건너뛰기


# 전략 2: Double Submit Cookie 패턴
# — 프론트엔드에서 CSRF 토큰을 헤더에 포함하여 전송
# settings.py:
CSRF_COOKIE_HTTPONLY = False     # JS에서 CSRF 토큰 읽기 허용
CSRF_HEADER_NAME = 'HTTP_X_CSRFTOKEN'

# 프론트엔드: X-CSRFToken 헤더에 csrftoken 쿠키 값을 포함하여 전송

@csrf_exempt 사용 시 주의점

# 위험한 패턴 — 이유 없는 csrf_exempt
@csrf_exempt  # 위험! 명확한 이유 없이 사용하지 말 것
def my_view(request):
    pass

# 허용 가능한 패턴 — 외부 웹훅 수신, API 전용 엔드포인트
@csrf_exempt
def stripe_webhook(request):
    """Stripe 웹훅은 서버 간 호출이므로 CSRF 불필요"""
    # 대신 Stripe 서명 검증으로 요청 인증
    sig = request.headers.get('Stripe-Signature')
    stripe.Webhook.construct_event(request.body, sig, endpoint_secret)

토큰 탈취 및 Replay Attack 대응

  • Access Token 수명 최소화: 15분 이하 권장
  • Refresh Token 순환: ROTATE_REFRESH_TOKENS = True로 매 갱신 시 새 refresh 토큰 발급
  • Refresh Token 재사용 탐지: 이미 블랙리스트된 토큰으로 갱신 시도 시 해당 사용자의 모든 토큰 무효화
  • IP 바인딩: 클레임에 발급 시 IP를 포함하고, 사용 시 비교 (모바일 환경에서는 주의)

체크리스트

Django 인증 구현 시 확인해야 할 항목:

  • SECRET_KEY가 환경 변수로 관리되고 있는가
  • ACCESS_TOKEN_LIFETIME이 15분 이하로 설정되어 있는가
  • REFRESH_TOKEN_LIFETIME이 적절한 기간(7일 이하)으로 설정되어 있는가
  • ROTATE_REFRESH_TOKENS = True로 설정되어 있는가
  • BLACKLIST_AFTER_ROTATION = True로 설정되어 있는가
  • 쿠키에 HttpOnly, Secure, SameSite 플래그가 설정되어 있는가
  • CORS_ALLOW_CREDENTIALS = True로 설정되어 있는가
  • CORS_ALLOWED_ORIGINS에 신뢰할 수 있는 오리진만 등록되어 있는가
  • CSRF_TRUSTED_ORIGINS가 CORS 설정과 일치하는가
  • 로그아웃 시 쿠키 삭제와 토큰 블랙리스트가 모두 처리되는가
  • flushexpiredtokens 명령이 주기적으로 실행되는가 (cron/Celery)
  • RS256 사용 시 공개키/비공개키가 안전하게 관리되는가
  • 프로덕션에서 DEBUG = False이며 ALLOWED_HOSTS가 설정되어 있는가
  • 비밀번호 해싱이 기본(PBKDF2) 이상의 알고리즘(Argon2, bcrypt)을 사용하는가
  • 커스텀 클레임에 민감한 정보(비밀번호, 개인정보)가 포함되지 않았는가

흔한 버그와 오해

1. "SessionAuthentication과 JWT를 함께 쓰면 CSRF 에러가 난다"

SessionAuthenticationenforce_csrf()를 호출하여 CSRF 토큰을 검증한다. JWT 전용 API에서는 DEFAULT_AUTHENTICATION_CLASSES에서 SessionAuthentication을 제거하거나, 커스텀 인증 클래스에서 CSRF 검증을 비활성화해야 한다.

2. "set_cookie()에서 domain을 설정하지 않으면 서브도메인에서 쿠키를 읽을 수 없다"

domain 파라미터 없이 설정된 쿠키는 정확히 해당 도메인에서만 유효하다. api.example.com에서 설정한 쿠키를 app.example.com에서 읽으려면 domain='.example.com'으로 설정해야 한다.

3. "ROTATE_REFRESH_TOKENS 없이 BLACKLIST_AFTER_ROTATION을 설정했다"

BLACKLIST_AFTER_ROTATIONROTATE_REFRESH_TOKENS = True일 때만 작동한다. 순환 없이 블랙리스트만 설정하면 아무 효과가 없다.

4. "SameSite=Strict로 설정했더니 외부 링크에서 로그인이 풀린다"

SameSite=Strict는 외부 사이트에서의 모든 요청에 쿠키를 포함하지 않는다. 이메일의 링크, SNS 공유 링크 등을 통해 사이트에 진입하면 쿠키가 전송되지 않아 로그인이 풀린 것처럼 보인다. 대부분의 경우 SameSite=Lax가 적절하다.

5. "refresh_token 쿠키의 path를 /로 설정하면 모든 요청에 불필요하게 전송된다"

Refresh Token은 갱신 엔드포인트에서만 필요하다. path='/api/token/refresh/'로 설정하면 해당 경로로의 요청에서만 쿠키가 전송되어 불필요한 노출을 줄일 수 있다.

6. "JWT 디코딩은 서버만 할 수 있다고 생각한다"

JWT의 header와 payload는 Base64URL 인코딩일 뿐 암호화되지 않는다. 서명(signature)만 서버의 비밀키로 검증된다. 따라서 민감한 정보를 클레임에 포함하면 안 된다. HttpOnly 쿠키에 저장해도 네트워크 스니핑(HTTPS 미사용 시)이나 서버 로그에서 노출될 수 있다.

7. "SimpleJWT의 TokenVerifyView가 토큰 유효성을 완벽하게 검증한다고 생각한다"

TokenVerifyView는 토큰의 서명과 만료만 확인한다. 블랙리스트에 있는 토큰인지, 사용자가 여전히 활성 상태인지는 확인하지 않는다. 프로덕션에서는 추가 검증 로직이 필요할 수 있다.

참고자료