- Overview - Django Authentication Architecture
- Session-based Authentication
- JWT-based Authentication (DRF + SimpleJWT)
- Storing JWT in Cookies
- Authentication Handling in Middleware
- Claim Parsing and User Info Access
- Browser Storage Accessibility Table
- CORS Configuration (django-cors-headers)
- Logout and Token Invalidation
- Token Refresh Strategy
- Security Tradeoffs
- Checklist
- Common Bugs and Misconceptions
- 1. "Using SessionAuthentication together with JWT produces a CSRF error"
- 2. "If you do not set domain in set_cookie(), subdomains cannot read the cookie"
- 3. "Setting BLACKLIST_AFTER_ROTATION without ROTATE_REFRESH_TOKENS"
- 4. "I set SameSite=Strict and now external links log people out"
- 5. "Setting the refresh_token cookie's path to / sends it unnecessarily on every request"
- 6. "Thinking only the server can decode a JWT"
- 7. "Thinking SimpleJWT's TokenVerifyView validates a token completely"
- References
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
↓
SecurityMiddleware ← HTTPS 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.
Cookie-based Login 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 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
Cookie-based DRF Authentication Class
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.
| Storage | JS Access | Auto Server Send | XSS Vuln. | CSRF Vuln. |
|---|---|---|---|---|
localStorage | O | X (added to a header by hand) | O — Vulnerable to theft | X |
sessionStorage | O | X | O — Vulnerable to theft | X |
| Regular cookie | O | O (same-origin) | O — Vulnerable to theft | O |
| HttpOnly cookie | X | O (same-origin) | X — No JS access | O — Mitigated by SameSite |
| HttpOnly + SameSite=Lax | X | O (same-origin GET) | X | Mostly blocked |
| HttpOnly + SameSite=Strict | X | Same site only | X | X |
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 = TrueandCORS_ALLOW_CREDENTIALS = Trueat the same time is a security risk. The browser rejects the combination ofAccess-Control-Allow-Origin: *andAccess-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
- Minimize the Access Token lifetime: 15 minutes or less is recommended
- Rotate the Refresh Token: with
ROTATE_REFRESH_TOKENS = True, a new refresh token is issued on every renewal - Detect Refresh Token reuse: when a renewal is attempted with an already-blacklisted token, invalidate every token belonging to that user
- IP binding: include the IP in a claim at issue time and compare it at use time (be careful on mobile)
Checklist
Items to check when implementing authentication in Django:
- Is the
SECRET_KEYmanaged through an environment variable? - Is
ACCESS_TOKEN_LIFETIMEset to 15 minutes or less? - Is
REFRESH_TOKEN_LIFETIMEset to a reasonable period (7 days or less)? - Is
ROTATE_REFRESH_TOKENS = Trueconfigured? - Is
BLACKLIST_AFTER_ROTATION = Trueconfigured? - Are the
HttpOnly,SecureandSameSiteflags set on the cookies? - Is
CORS_ALLOW_CREDENTIALS = Trueconfigured? - Are only trusted origins registered in
CORS_ALLOWED_ORIGINS? - Does
CSRF_TRUSTED_ORIGINSmatch the CORS configuration? - Does logout handle both deleting the cookies and blacklisting the token?
- Does the
flushexpiredtokenscommand run periodically (cron/Celery)? - When using RS256, are the public and private keys managed safely?
- In production, is
DEBUG = Falseand isALLOWED_HOSTSconfigured? - Does password hashing use an algorithm at or above the default (PBKDF2), such as Argon2 or bcrypt?
- Are sensitive details (passwords, personal information) kept out of the custom claims?
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.
2. "If you do not set domain in set_cookie(), subdomains cannot read the cookie"
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.
4. "I set SameSite=Strict and now external links log people out"
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
- Django Authentication System — Django's official authentication documentation
- Django REST Framework - Authentication — the DRF authentication guide
- djangorestframework-simplejwt Documentation — the official SimpleJWT documentation
- RFC 7519 - JSON Web Token (JWT) — the JWT standard specification
- PyJWT Documentation — the Python JWT library documentation
- django-cors-headers — the CORS header middleware
- OWASP - JSON Web Token Cheat Sheet — the OWASP JWT security guide
- OWASP - Cross-Site Request Forgery Prevention — a CSRF defense guide
- Django Security - CSRF Protection — Django's CSRF protection documentation
- MDN - Set-Cookie — the Set-Cookie header reference
- MDN - SameSite cookies — an explanation of the SameSite attribute
- RFC 6749 - The OAuth 2.0 Authorization Framework — the OAuth 2.0 specification