WEBBYFOX-OS PATH /blog/django-security-checklist/ DOC ~17 MIN NODE LDN-01
FEED ACTIVE 13:42 BST
./post · django-security-checklist.md
NEW
// ARTICLE · JUN 23, 2026

Django Security Checklist: OWASP, Cryptography & Zero-Trust.

Django is secure by default — and almost every Django breach I've been called in on happened anyway, because a default got switched off, a permission check got skipped, or a secret ended up in version control. This is the checklist I actually run: the settings that matter, the OWASP Top 10 mapped to concrete Django defenses, authentication and session hardening with Argon2, cryptography done right with signing and field encryption, a zero-trust posture for views and services, and the supply-chain checks that catch the vulnerability you didn't write.

Python Django Security OWASP Cryptography
· ~17 min read ·
$ cat ./django-security-checklist.md
READ

Secure by Default, Breached by Configuration

The single most important thing to understand about Django security is that the framework's defaults are genuinely good. The ORM parametrizes queries, so SQL injection is off the table unless you reach for raw SQL. Templates autoescape, so cross-site scripting is off the table unless you mark something safe. CSRF protection is middleware you'd have to actively remove. Passwords are salted and stretched. Out of the box, Django defends against most of the OWASP Top 10.

So the threat model isn't "is Django insecure?" — it's "which default did we quietly disable, and where did we step outside the framework's protection?" Every breach in this post comes from that question: DEBUG=True in production, a SECRET_KEY in the repo, a view that trusts a URL parameter, a |safe on user input, a dependency three levels deep with a CVE. The checklist below is organized around those failure points, not around theoretical attacks.


The Settings That Matter

Most production hardening is a dozen settings. Here's the security-critical block, annotated. None of this is exotic — it's the difference between Django's defaults being active and being aspirational.

# settings/production.py
import os

# --- The three that cause the most incidents ---
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]   # never hard-code; never commit
DEBUG = False                                  # tracebacks leak code, settings, SQL
ALLOWED_HOSTS = ["example.com", "www.example.com"]   # block Host-header attacks

# --- Force HTTPS ---
SECURE_SSL_REDIRECT = True
SECURE_HSTS_SECONDS = 31_536_000               # 1 year
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
# If behind a proxy/load balancer that terminates TLS:
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

# --- Cookies: HTTPS-only, not readable by JS ---
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True                 # default True; keep it
CSRF_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"                # "Strict" if no cross-site flows

# --- Browser-side hardening ---
SECURE_CONTENT_TYPE_NOSNIFF = True
X_FRAME_OPTIONS = "DENY"                        # clickjacking (needs XFrameOptionsMiddleware)

Three of those deserve emphasis because they cause the most real incidents:

  • SECRET_KEY signs sessions, password-reset tokens, and anything using django.core.signing. If it leaks, an attacker can forge those. Pull it from the environment or a secrets manager, never from source. If it has ever been committed, rotate it — and know that rotating it invalidates all active sessions and signed tokens.
  • DEBUG = False in production, always. A True here turns every 500 into a full traceback with local variables, settings, and SQL — a reconnaissance gift.
  • ALLOWED_HOSTS must be an explicit list. It's your defense against Host-header poisoning that can corrupt password-reset links and cache keys.

The OWASP Top 10, Mapped to Django

The OWASP Top 10 is the industry's shared vocabulary for web risk. Here's how the big categories land in a Django codebase, and the one-line rule for each.

OWASP categoryDjango defaultWhere it breaks
A01 Broken Access ControlNothing automaticViews that don't scope by user
A02 Cryptographic FailuresSalted hashing, signingPlaintext secrets, weak SECRET_KEY
A03 Injection (SQLi)ORM parametrizes.raw(), .extra(), string SQL
A03 Injection (XSS)Template autoescapemark_safe, |safe
A05 Security MisconfigurationGood defaultsDEBUG=True, disabled middleware
A07 Auth FailuresPassword validatorsNo rate limiting, weak hasher
A10 SSRFNothing automaticFetching user-supplied URLs

A03 — SQL injection: stay inside the ORM

The ORM parametrizes everything. Injection re-enters the moment you build SQL from strings. The rule: if you must use raw(), pass parameters as the second argument — never with f-strings or %.

# DANGER — user input concatenated into SQL
User.objects.raw(f"SELECT * FROM auth_user WHERE username = '{name}'")
User.objects.extra(where=[f"username = '{name}'"])   # .extra() is a footgun

# SAFE — parameters are escaped by the driver
User.objects.raw("SELECT * FROM auth_user WHERE username = %s", [name])
# SAFER — just use the ORM
User.objects.filter(username=name)

A03 — XSS: don't un-escape user input

Django templates escape <, >, &, ' and " automatically. XSS comes back when you override that with mark_safe or the |safe filter on data a user controls.

# DANGER — renders attacker HTML/JS verbatim
from django.utils.safestring import mark_safe
mark_safe(f"<span>{user_bio}</span>")          # XSS if user_bio is hostile

# SAFE — format_html escapes the arguments, trusts only the template
from django.utils.html import format_html
format_html("<span>{}</span>", user_bio)         # user_bio is auto-escaped

To pass data to JavaScript, never interpolate into a <script> block — use the json_script template filter, which safely serializes and escapes the payload.

A01 — Broken access control: the number-one risk

This is the category Django won't save you from, and it's the most common serious bug I find in audits. A view that loads an object by pk without checking that the current user is allowed to see it is an Insecure Direct Object Reference (IDOR) — change the URL number, read someone else's invoice.

# DANGER — any logged-in user can read any invoice by guessing the id
def invoice_detail(request, pk):
    invoice = get_object_or_404(Invoice, pk=pk)
    return render(request, "invoice.html", {"invoice": invoice})

# SAFE — scope the queryset to the requester. The 404 also hides existence.
def invoice_detail(request, pk):
    invoice = get_object_or_404(Invoice, pk=pk, owner=request.user)
    return render(request, "invoice.html", {"invoice": invoice})

A10 — SSRF: validate outbound URLs

If your app fetches a user-supplied URL (webhooks, "import from link", avatar-by-URL), an attacker can point it at http://169.254.169.254/ or internal services. Allowlist schemes and hosts, resolve the hostname, and reject private IP ranges before you make the request.


Authentication & Session Hardening

Django's auth framework is solid; the hardening is in the dials around it.

Use Argon2 for password hashing

Django's default PBKDF2 is fine, but Argon2 is the modern, memory-hard recommendation. Install argon2-cffi and list it first — Django transparently re-hashes each user's password to Argon2 on their next login.

# pip install argon2-cffi
PASSWORD_HASHERS = [
    "django.contrib.auth.hashers.Argon2PasswordHasher",
    "django.contrib.auth.hashers.PBKDF2PasswordHasher",
    "django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher",
]

AUTH_PASSWORD_VALIDATORS = [
    {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
    {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
     "OPTIONS": {"min_length": 12}},
    {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
    {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]

Sessions

  • Rotate the session key on login. Django's login() already cycles the key, defeating session fixation — don't bypass it with manual session juggling.
  • Set a sane lifetime. SESSION_COOKIE_AGE (e.g. two weeks) and consider SESSION_EXPIRE_AT_BROWSER_CLOSE = True for sensitive apps.
  • Log out everywhere on password change. Django invalidates other sessions on password change when SessionAuthenticationMiddleware is active (it is, by default).

Rate-limit authentication

Django ships no brute-force protection. Add it — django-axes locks accounts/IPs after repeated failures, or django-ratelimit throttles the login view directly. Without it, the login form is an open password-guessing oracle.

# pip install django-ratelimit
from django_ratelimit.decorators import ratelimit

@ratelimit(key="ip", rate="5/m", method="POST", block=True)
def login_view(request):
    ...   # 5 POST attempts per IP per minute, then HTTP 429

Pair this with multi-factor authentication (django-otp or a hosted IdP) for anything privileged. MFA is the single highest-leverage control against credential theft.


Cryptography Done Right

The first rule of application cryptography is: don't invent it. Django and the cryptography library give you vetted primitives for the three things apps actually need — hashing, signing, and encryption — and they are not interchangeable.

GoalToolReversible?
Store passwordsmake_password / Argon2No (hash)
Tamper-proof a tokendjango.core.signingReadable, not secret
Hide data at restFernet (AES)Yes (encrypt)
Random tokenssecretsn/a

Signing: tamper-evident tokens without a database

For password-reset links, email-confirmation tokens, or "unsubscribe" URLs, you don't need to store anything — sign it. TimestampSigner produces a value the user can read but cannot forge or replay past an expiry.

from django.core.signing import TimestampSigner, BadSignature, SignatureExpired

signer = TimestampSigner()

# create a token (e.g. emailed to the user)
token = signer.sign(str(user.pk))

# verify it, rejecting anything older than 1 hour or tampered with
try:
    user_pk = signer.unsign(token, max_age=3600)
except SignatureExpired:
    ...   # link expired
except BadSignature:
    ...   # forged or corrupted

Encryption: protecting data at rest

When you must store something readable-back but sensitive — an API key for a third party, a TOTP seed — encrypt it with Fernet (authenticated AES-128 in CBC mode). Keep the key in your secrets manager, separate from SECRET_KEY, and rotate with MultiFernet.

# pip install cryptography
import os
from cryptography.fernet import Fernet
from django.db import models

fernet = Fernet(os.environ["FIELD_ENCRYPTION_KEY"])   # Fernet.generate_key()

class EncryptedTextField(models.TextField):
    """Transparently encrypts on save, decrypts on load."""
    def get_prep_value(self, value):
        if value is None:
            return value
        return fernet.encrypt(value.encode()).decode()

    def from_db_value(self, value, expression, connection):
        if value is None:
            return value
        return fernet.decrypt(value.encode()).decode()

class Integration(models.Model):
    third_party_api_key = EncryptedTextField()

Zero-Trust in a Django App

Zero-trust means one thing in practice: never grant access based on network position. "It's behind the VPN" and "it's an internal service" are not authentication. Every request — from a browser, a mobile app, or another microservice — proves who it is and is authorized for the specific resource it's touching. Translated into Django, that's four habits.

  • Authorize at the data layer, every time. The queryset-scoping pattern above isn't just an OWASP fix — it's the core zero-trust move. Assume the caller is hostile and let the query enforce the boundary, so there's no trusted-by-default path to the object.
  • Least privilege for the database. The app's DB user rarely needs DROP, CREATE, or superuser. Run migrations as one role, serve traffic as a narrower one. A SQL-injection or RCE then inherits far less power.
  • Authenticate service-to-service calls. Internal APIs get short-lived signed tokens (JWT) or mTLS — not an IP allowlist. A compromised pod inside your network should not be able to call your billing service just because it can reach it.
  • Verify every request independently. No "trusted" endpoints that skip auth because they're "only called internally." Internal is a network fact, not a trust boundary.

Dependency & Supply-Chain Security

The vulnerability that gets you is usually one you didn't write — it's three levels deep in your dependency tree. A2026-era Django app pulls in dozens of transitive packages, any of which can ship a CVE. You need this in CI, not in a quarterly review.

# Audit installed packages against known-vulnerability databases
pip install pip-audit
pip-audit                       # fails CI on any known CVE

# Alternative / complementary
pip install safety
safety check

# Pin everything, with hashes, so installs are reproducible & tamper-evident
pip install pip-tools
pip-compile --generate-hashes requirements.in -o requirements.txt
pip install --require-hashes -r requirements.txt
  • Pin with hashes. --require-hashes means a tampered or substituted package fails the install — your defense against a compromised mirror or typosquat.
  • Automate upgrades. Dependabot or Renovate opens PRs for security releases so patching isn't manual.
  • Keep Django on a supported release. Only the current and LTS branches get security fixes. Running an end-of-life Django means living with unpatched CVEs by definition.
  • Audit your own check output. manage.py check --deploy belongs in the same CI stage as pip-audit — config drift is a supply-chain risk too.

Production Security Checklist

  • Run manage.py check --deploy in CI and fail on warnings. It's the cheapest audit you'll ever run.
  • DEBUG = False, explicit ALLOWED_HOSTS, SECRET_KEY from the environment. These three prevent the most common, most severe misconfigurations.
  • Force HTTPS: SECURE_SSL_REDIRECT, year-long HSTS with preload, and Secure + HttpOnly + SameSite on session and CSRF cookies.
  • Scope every queryset to the requester. get_object_or_404(Model, pk=pk, owner=request.user) — broken access control is the risk Django won't catch for you.
  • Stay inside the ORM. If you must use raw(), parametrize. Never f-string SQL. Avoid .extra() entirely.
  • Never mark_safe / |safe user input. Reach for format_html and json_script instead.
  • Argon2 hasher, 12-char minimum, MFA on privileged accounts, rate-limited login. Django gives you the framework; you supply the brute-force defense.
  • Right tool per crypto job: hash passwords, sign tokens with django.core.signing, encrypt data at rest with Fernet, generate randomness with secrets.
  • Zero-trust: authorize at the data layer, least-privilege DB roles, authenticate service-to-service calls — never trust the network.
  • Supply chain: pip-audit in CI, hash-pinned requirements, automated security upgrades, a supported Django version.

Security isn't a feature you finish; it's the set of defaults you refuse to disable and the checks you refuse to skip. The reason Django apps get breached is almost never a flaw in Django — it's a team that stepped outside the framework's protection without noticing. Run the checklist on every deploy, and most of the OWASP Top 10 simply never becomes your problem.

$ ls ./related/
3 POSTS
$ cd ../ · · ↗ RSS feed · ↑ top