LabHub

Blog

Django Authentication Practical Guide - Sessions, DRF + SimpleJWT, Middleware, Cookie-based Auth Complete Mastery

한국어English日本語

SSO Cookie/JWT Authentication Series · Spring Boot Edition · Current: Django Edition · React Edition

Overview - Django Authentication Architecture

Django ships a powerful authentication framework out of the box through the django.contrib.auth module. The user model, the permission system, password hashing and session-based authentication all work without installing anything else. In an SPA frontend or a microservice architecture, however, session-based authentication alone is often not enough, so you have to use JWT (JSON Web Token) based authentication alongside it.

Django's authentication flow is determined by the middleware stack.

Request received
SecurityMiddlewareHTTPS redirect, HSTS settings
SessionMiddleware           ← loads the session from the session_key cookie → binds request.session
AuthenticationMiddleware    ← extracts user_id from request.session → binds request.user
View or DRF APIView         ← request.user is available

When you use DRF (Django REST Framework), authentication is handled by the authentication classes configured in DEFAULT_AUTHENTICATION_CLASSES. You can combine SessionAuthentication, TokenAuthentication, JWTAuthentication and others.

# 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',
]

Session-based Authentication

Django's session authentication is the traditional approach: session data is stored on the server side and a sessionid cookie is issued to the client.

Session Backend Types

# settings.py — session backend configuration

# 1. DB-based (the default)
SESSION_ENGINE = 'django.contrib.sessions.backends.db'

# 2. Cache-based (Redis/Memcached)
SESSION_ENGINE = 'django.contrib.sessions.backends.cache'
SESSION_CACHE_ALIAS = 'default'

# 3. Cache + DB combined (writes go to the DB, reads prefer the cache)
SESSION_ENGINE = 'django.contrib.sessions.backends.cached_db'

# 4. Cookie-based (nothing stored on the server — the session data lives in a signed cookie)
SESSION_ENGINE = 'django.contrib.sessions.backends.signed_cookies'

# common settings
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SECURE = True        # HTTPS only
SESSION_COOKIE_SAMESITE = 'Lax'
SESSION_COOKIE_AGE = 1209600        # 2 weeks (in seconds)
SESSION_COOKIE_DOMAIN = '.example.com'  # shared across subdomains

Login/Logout Implementation

# 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):
    """session-based login"""
    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)  # stores user_id in the session + issues the sessionid cookie
        return JsonResponse({
            'message': 'Login successful',
            'user': {
                'id': user.id,
                'username': user.username,
                'email': user.email,
            }
        })
    return JsonResponse({'error': 'Authentication failed'}, status=401)


@require_POST
def session_logout_view(request):
    """session-based logout"""
    logout(request)  # deletes the session data + invalidates the sessionid cookie
    return JsonResponse({'message': 'Logout complete'})


def profile_view(request):
    """accessing 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': 'Unauthenticated user'}, status=401)

The authenticate() function walks the backends registered in AUTHENTICATION_BACKENDS and verifies the credentials. The login() function stores _auth_user_id, _auth_user_backend and _auth_user_hash in the session and sets the sessionid cookie on the response.

JWT-based Authentication (DRF + SimpleJWT)

For an SPA frontend, a mobile app, or communication between microservices, stateless JWT authentication is a better fit. In Django the djangorestframework-simplejwt library is the de facto standard.

SimpleJWT Configuration

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 = {
    # token lifetimes
    'ACCESS_TOKEN_LIFETIME': timedelta(minutes=15),
    'REFRESH_TOKEN_LIFETIME': timedelta(days=7),

    # Refresh Token rotation — when True, a new refresh token is issued on every refresh
    'ROTATE_REFRESH_TOKENS': True,
    'BLACKLIST_AFTER_ROTATION': True,

    # algorithm and keys
    'ALGORITHM': 'HS256',
    'SIGNING_KEY': SECRET_KEY,
    # when using RS256:
    # 'ALGORITHM': 'RS256',
    # 'SIGNING_KEY': open('/path/to/private.pem').read(),
    # 'VERIFYING_KEY': open('/path/to/public.pem').read(),

    # headers
    'AUTH_HEADER_TYPES': ('Bearer',),
    'AUTH_HEADER_NAME': 'HTTP_AUTHORIZATION',

    # user identifier
    'USER_ID_FIELD': 'id',
    'USER_ID_CLAIM': 'user_id',

    # token types
    'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',),
    'TOKEN_TYPE_CLAIM': 'token_type',

    # JTI (JWT ID) — the token's unique identifier
    'JTI_CLAIM': 'jti',

    # sliding tokens (optional)
    'SLIDING_TOKEN_REFRESH_EXP_CLAIM': 'refresh_exp',
    'SLIDING_TOKEN_LIFETIME': timedelta(minutes=5),
    'SLIDING_TOKEN_REFRESH_LIFETIME': timedelta(days=1),
}

URL Configuration

# 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'),
]

If you send a username and a password to POST /api/token/, the access and refresh tokens come back as JSON.

Adding Custom Claims

The default JWT payload contains only user_id, token_type, exp, iat and jti. To include extra information such as the user's roles or a tenant ID, customize the serializer.

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

        # add custom claims
        token['username'] = user.username
        token['email'] = user.email
        token['is_staff'] = user.is_staff

        # user roles (assuming a ManyToMany relation)
        token['roles'] = list(user.groups.values_list('name', flat=True))

        # multi-tenant environment — pull tenant_id from the user profile
        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)
        # include the extra information in the response 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

An example of the JWT payload that gets produced:

{
  "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"
}

Storing JWT in Cookies

In an SPA environment, storing the JWT in localStorage leaves it vulnerable to XSS attacks. To strengthen security, store the JWT in an HttpOnly cookie so that JavaScript cannot reach it.

# 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):
    """a login view that stores the JWT in an HttpOnly cookie"""
    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
        )

    # create the tokens
    refresh = RefreshToken.for_user(user)

    # add custom claims
    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': 'Login successful',
        'user': {
            'id': user.id,
            'username': user.username,
            'email': user.email,
        }
    })

    # set the Access Token cookie
    response.set_cookie(
        key='access_token',
        value=access_token,
        max_age=settings.SIMPLE_JWT['ACCESS_TOKEN_LIFETIME'].total_seconds(),
        httponly=True,       # no access from JavaScript
        secure=True,         # sent over HTTPS only
        samesite='Lax',      # CSRF protection (blocks cross-site POST)
        domain='.example.com',  # shared across subdomains
        path='/',
    )

    # set the Refresh Token cookie
    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/',  # sent only on the refresh endpoint
    )

    return response

DRF's stock JWTAuthentication reads the token from the Authorization header. Reading it from a cookie requires a custom authentication class.

# 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):
    """a DRF authentication class that reads the JWT from a cookie"""

    def authenticate(self, request):
        # 1. read access_token from the cookie
        raw_token = request.COOKIES.get('access_token')

        if raw_token is None:
            # not in the cookie, so fall back to the standard header approach
            return super().authenticate(request)

        # 2. verify the token
        try:
            validated_token = self.get_validated_token(raw_token)
        except (InvalidToken, TokenError) as e:
            return None  # authentication failed — handled as AnonymousUser

        # 3. look up the user object from the token
        user = self.get_user(validated_token)
        return (user, validated_token)
# register it in settings.py
REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': (
        'myapp.authentication.CookieJWTAuthentication',
        'rest_framework.authentication.SessionAuthentication',
    ),
}

Authentication Handling in Middleware

If you want cookie-based JWT authentication in plain Django Views as well as in DRF's APIView, handling it in middleware is the effective approach.

# 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:
    """
    Middleware that reads the JWT from the cookie and sets request.user.
    It has to be placed after AuthenticationMiddleware.
    Users already authenticated by session authentication are skipped.
    """

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

    def __call__(self, request):
        # skip when already authenticated by session
        if hasattr(request, 'user') and request.user.is_authenticated:
            return self.get_response(request)

        # extract access_token from the cookie
        access_token = request.COOKIES.get('access_token')

        if access_token:
            try:
                # verify the token
                validated_token = AccessToken(access_token)

                # look up the user
                user_id = validated_token.get('user_id')
                user = User.objects.get(id=user_id)

                # bind the user and the token info onto the request
                request.user = user
                request.jwt_token = validated_token
                request.jwt_claims = validated_token.payload

            except (TokenError, InvalidToken) as e:
                logger.warning(f'JWT authentication failed: {e}')
                request.user = AnonymousUser()
            except User.DoesNotExist:
                logger.warning(f'No user matches the JWT user_id: {user_id}')
                request.user = AnonymousUser()

        response = self.get_response(request)
        return response
# settings.py — the middleware registration order matters!
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',  # after AuthenticationMiddleware!
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

Claim Parsing and User Info Access

Accessing JWT Info from Request

Once JWT authentication has completed through the middleware or the authentication class, a View can reach the user information in several ways.

# 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):
    """accessing the information from 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):
    """decoding the JWT claims directly"""

    # approach 1: use the claims bound by the middleware
    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'),
        })

    # approach 2: use the token bound by DRF's 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'),
        })

    # approach 3: decode straight from the cookie (using 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)

Custom Permission Classes

# permissions.py
from rest_framework.permissions import BasePermission


class HasRole(BasePermission):
    """a permission class that checks the role in the JWT claims"""

    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

        # use the required_role attribute if the view defines one
        required = getattr(view, 'required_role', self.required_role)
        if required is None:
            return True

        # check the role in the JWT claims
        if hasattr(request, 'jwt_claims'):
            roles = request.jwt_claims.get('roles', [])
            return required in roles

        # check the role in the DB (fallback)
        return request.user.groups.filter(name=required).exists()


class IsSameTenant(BasePermission):
    """checks that the requesting user's tenant_id matches the resource's"""

    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 — an example of using the permissions
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': 'Admin dashboard'})


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

Browser Storage Accessibility Table

The security characteristics change depending on where you store the JWT.

StorageJS AccessAuto Server SendXSS Vuln.CSRF Vuln.
localStorageOX (added to a header by hand)O — Vulnerable to theftX
sessionStorageOXO — Vulnerable to theftX
Regular cookieOO (same-origin)O — Vulnerable to theftO
HttpOnly cookieXO (same-origin)X — No JS accessO — Mitigated by SameSite
HttpOnly + SameSite=LaxXO (same-origin GET)XMostly blocked
HttpOnly + SameSite=StrictXSame site onlyXX

Recommended: store it in a cookie with the HttpOnly + Secure + SameSite=Lax combination, and add a CSRF token or a custom header to state-changing requests (POST, PUT, DELETE).

CORS Configuration (django-cors-headers)

For an SPA frontend on a different domain to call the API, CORS configuration is mandatory.

pip install django-cors-headers
# settings.py

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

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'corsheaders.middleware.CorsMiddleware',  # must come before CommonMiddleware
    'django.middleware.common.CommonMiddleware',
    # ...
]

# === CORS settings ===

# the origins to allow (credentials cannot be used with a wildcard)
CORS_ALLOWED_ORIGINS = [
    'https://app.example.com',
    'https://admin.example.com',
    'http://localhost:3000',  # development environment
]

# allow cross-origin requests that include cookies (withCredentials: true)
CORS_ALLOW_CREDENTIALS = True

# the headers to allow
CORS_ALLOW_HEADERS = [
    'accept',
    'accept-encoding',
    'authorization',
    'content-type',
    'dnt',
    'origin',
    'user-agent',
    'x-csrftoken',
    'x-requested-with',
]

# how long a preflight response is cached
CORS_PREFLIGHT_MAX_AGE = 86400  # 24 hours

# === CSRF settings ===

# keep this identical to CORS_ALLOWED_ORIGINS
CSRF_TRUSTED_ORIGINS = [
    'https://app.example.com',
    'https://admin.example.com',
]

# CSRF cookie settings
CSRF_COOKIE_HTTPONLY = False  # False, because JS has to read it
CSRF_COOKIE_SECURE = True
CSRF_COOKIE_SAMESITE = 'Lax'
CSRF_COOKIE_DOMAIN = '.example.com'

Note: setting CORS_ALLOW_ALL_ORIGINS = True and CORS_ALLOW_CREDENTIALS = True at the same time is a security risk. The browser rejects the combination of Access-Control-Allow-Origin: * and Access-Control-Allow-Credentials: true.

Logout and Token Invalidation

A JWT is stateless by nature, which makes forced invalidation on the server hard. SimpleJWT's token blacklist feature solves this problem.

Blacklist Configuration

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

# the migration has to be run
# python manage.py migrate

The blacklist creates two models, OutstandingToken and BlacklistedToken. OutstandingToken stores the refresh tokens that have been issued, and BlacklistedToken stores the tokens that have been invalidated.

Logout 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 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):
    """cookie-based JWT logout — blacklist + delete the cookies"""
    refresh_token = request.COOKIES.get('refresh_token')

    if refresh_token:
        try:
            token = RefreshToken(refresh_token)
            token.blacklist()  # add the token to the blacklist
        except TokenError:
            pass  # already expired, or an invalid token

    response = Response({'message': 'Logout complete'}, status=status.HTTP_200_OK)

    # delete the cookies
    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

Expired Token Cleanup (cron/celery)

# management/commands/flush_expired_tokens.py
# use the command SimpleJWT provides out of the box:
# python manage.py flushexpiredtokens

# Celery beat schedule configuration
CELERY_BEAT_SCHEDULE = {
    'flush-expired-tokens': {
        'task': 'myapp.tasks.flush_expired_tokens',
        'schedule': 86400,  # daily
    },
}

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

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

Token Refresh Strategy

When the Access Token expires, a new Access Token has to be issued using the Refresh Token. In a cookie-based environment you implement a dedicated refresh view.

# 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):
    """reads refresh_token from the cookie and issues a new access_token + refresh_token"""
    refresh_token = request.COOKIES.get('refresh_token')

    if not refresh_token:
        return Response(
            {'error': 'There is no refresh token.'},
            status=status.HTTP_401_UNAUTHORIZED
        )

    try:
        old_refresh = RefreshToken(refresh_token)

        # issue a new access token
        new_access = str(old_refresh.access_token)

        # when ROTATE_REFRESH_TOKENS=True, issue a new refresh token
        if settings.SIMPLE_JWT.get('ROTATE_REFRESH_TOKENS', False):
            # blacklist the previous refresh token
            if settings.SIMPLE_JWT.get('BLACKLIST_AFTER_ROTATION', False):
                old_refresh.blacklist()

            # create the new refresh token
            new_refresh = RefreshToken.for_user(old_refresh.payload.get('user_id'))
            # copy over the existing custom claims
            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  # reuse the existing token

        response = Response({'message': 'Token refresh successful'})

        # set the new access token cookie
        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='/',
        )

        # set the new refresh token cookie (when it was rotated)
        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'Invalid refresh token: {str(e)}'},
            status=status.HTTP_401_UNAUTHORIZED
        )

The refresh flow, summarized:

1. Client: API request → 401 Unauthorized (access_token expired)
2. Client: POST /api/token/refresh/ (the refresh_token cookie is sent automatically)
3. Server: verify refresh_token → issue a new access_token + refresh_token → set the cookies
4. Client: retry the original API request → 200 OK

Security Tradeoffs

Handling XSS

Storing the JWT in an HttpOnly cookie means JavaScript cannot reach the token, which prevents token theft through an XSS attack. If an XSS vulnerability exists, however, the attacker can send API requests on the user's behalf, so input validation and output escaping are still mandatory.

Handling CSRF

Cookie-based authentication is vulnerable to CSRF (Cross-Site Request Forgery) attacks. Django provides CSRF protection through CsrfViewMiddleware, but it can conflict with JWT authentication when the two are used together.

# strategies for using CSRF and JWT cookies together

# Strategy 1: keep the CSRF protection (recommended)
# — SessionAuthentication forces CSRF verification, so either remove it,
#    or override enforce_csrf() in CookieJWTAuthentication
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):
        """disable CSRF verification for cookie-based JWT
        (because the SameSite cookie already defends against CSRF)"""
        return  # skip CSRF verification


# Strategy 2: the Double Submit Cookie pattern
# — the frontend includes the CSRF token in a header
# settings.py:
CSRF_COOKIE_HTTPONLY = False     # allow JS to read the CSRF token
CSRF_HEADER_NAME = 'HTTP_X_CSRFTOKEN'

# frontend: send the csrftoken cookie value in the X-CSRFToken header

Cautions When Using @csrf_exempt

# a dangerous pattern — csrf_exempt without a reason
@csrf_exempt  # dangerous! do not use it without a clear reason
def my_view(request):
    pass

# an acceptable pattern — receiving external webhooks, API-only endpoints
@csrf_exempt
def stripe_webhook(request):
    """a Stripe webhook is a server-to-server call, so CSRF is not needed"""
    # instead, authenticate the request by verifying the Stripe signature
    sig = request.headers.get('Stripe-Signature')
    stripe.Webhook.construct_event(request.body, sig, endpoint_secret)

Handling Token Theft and Replay Attacks

Checklist

Items to check when implementing authentication in Django:

Common Bugs and Misconceptions

1. "Using SessionAuthentication together with JWT produces a CSRF error"

SessionAuthentication calls enforce_csrf() to verify the CSRF token. In a JWT-only API you have to remove SessionAuthentication from DEFAULT_AUTHENTICATION_CLASSES, or disable CSRF verification in your custom authentication class.

A cookie set without the domain parameter is valid only on exactly that domain. For a cookie set on api.example.com to be readable on app.example.com, you have to set domain='.example.com'.

3. "Setting BLACKLIST_AFTER_ROTATION without ROTATE_REFRESH_TOKENS"

BLACKLIST_AFTER_ROTATION only works when ROTATE_REFRESH_TOKENS = True. Configuring the blacklist without rotation has no effect at all.

SameSite=Strict does not include the cookie on any request from an external site. When a user enters the site through a link in an email or a link shared on social media, the cookie is not sent and it looks as though they have been logged out. In most cases SameSite=Lax is appropriate.

5. "Setting the refresh_token cookie's path to / sends it unnecessarily on every request"

The Refresh Token is needed only at the refresh endpoint. Setting path='/api/token/refresh/' means the cookie is only sent on requests to that path, which reduces unnecessary exposure.

6. "Thinking only the server can decode a JWT"

A JWT's header and payload are merely Base64URL-encoded, not encrypted. Only the signature is verified with the server's secret key. Sensitive information therefore must not be included in the claims. Even stored in an HttpOnly cookie, it can be exposed through network sniffing (when HTTPS is not used) or in server logs.

7. "Thinking SimpleJWT's TokenVerifyView validates a token completely"

TokenVerifyView checks only the token's signature and expiry. It does not check whether the token is on the blacklist, or whether the user is still active. In production you may need additional verification logic.

References

Comments

No comments yet.

Sign in to leave a comment