Filter concepts by levelShowing all levels.

Django · Security

Django Security

The defences Django ships, the settings it cannot set for you, the risks it does not cover, and check --deploy

  • The framework is not the vulnerabilitythe line that opts out of a default is — grep for those four shapes
  • The production settings are off by defaultDjango cannot know your hostnames, your proxy, or your TLS
  • SSRF, redirects, uploads are yoursuser input used as an instruction, not as data
  • HSTS has no server-side undoa browser honours the last max-age it saw, whatever you serve next
  • check --deploy, against production settingspointed at dev settings it passes and means nothing

1.CSRF, XSS, SQL injection, and clickjacking

Django defends against all four of these by default, and each defence has one specific way to switch it off. **CSRF** — `CsrfViewMiddleware` requires a secret token on unsafe methods, so a form on another site cannot make an authenticated request on the user's behalf; `@csrf_exempt` removes it. **XSS** — templates autoescape the characters that are dangerous in HTML; `|safe`, `mark_safe()` and `{% autoescape off %}` remove it. **SQL injection** — the ORM parameterises every query, sending SQL and values separately; string-formatting into `.raw()`, `extra()` or `RawSQL()` removes it. **Clickjacking** — `XFrameOptionsMiddleware` sends `X-Frame-Options: DENY`, so your pages cannot be loaded in an invisible frame over a page the attacker controls. Knowing where each defence ends is more useful than knowing that it exists.

Key features

  • CSRF protection applies to unsafe methods (`POST`, `PUT`, `PATCH`, `DELETE`); DRF additionally enforces it only for session-authenticated requests, not anonymous ones
  • Autoescaping covers element content; an **unquoted** HTML attribute is a separate context where an escaped value can still execute — the Django docs give `<style class={{ var }}>` as the example
  • `mark_safe()`, the `|safe` filter, and `{% autoescape off %}` each disable escaping for their scope — treat every use as a line that needs a reason beside it
  • The ORM parameterises: SQL text and values travel separately, so a value can never become syntax. `.raw()` and `cursor.execute()` do too — **if** you pass `params`, not an f-string
  • `extra()` and `RawSQL()` are the two the Django docs single out for caution, because parts of what you pass are interpolated into SQL rather than parameterised
  • `X_FRAME_OPTIONS` defaults to `"DENY"` with `XFrameOptionsMiddleware` enabled; `@xframe_options_exempt` opts one view out

Four defences, and the exact line that disables each

Four defences, and the exact line that disables each
AttackDjango's defenceWhat switches it offWhat that then allows
CSRF`CsrfViewMiddleware` + `{% csrf_token %}``@csrf_exempt`another site submitting as the logged-in user
XSStemplate autoescaping`|safe`, `mark_safe()`, `{% autoescape off %}`stored user text executing as script
XSS (attribute)— not coveredan unquoted attribute: `class={{ v }}`an event handler injected with no escape needed
SQL injectionORM query parameterisationf-strings in `.raw()` / `extra()` / `RawSQL()`a value becoming SQL syntax
Clickjacking`X-Frame-Options: DENY``@xframe_options_exempt`your page framed invisibly over a decoy
Where untrusted input crosses into an interpreter — and the one line that opens each gate

Into the HTML parser

Autoescaped by default

the dangerous HTML characters are escaped in element content

|safe · mark_safe()

every use needs a reason written next to it

Unquoted attributes

class={{ v }} executes — autoescaping does not cover this context

Into the SQL parser

The ORM parameterises everything

SQL and values travel separately

.raw(sql, params) is safe too

the params list is what makes it safe, not the method

f-string · extra() · RawSQL()

the three shapes to grep for in review

Into your endpoint, from elsewhere

CsrfViewMiddleware

a secret token on POST/PUT/PATCH/DELETE

X-Frame-Options: DENY

the default — your page cannot be framed

@csrf_exempt · @xframe_options_exempt

the two decorators worth a second reviewer

  • Untrusted input
  • Into the HTML parser — XSS
    • Autoescaped by default — the dangerous HTML characters are escaped in element content
    • |safe · mark_safe() — every use needs a reason written next to it
    • Unquoted attributes — class={{ v }} executes — autoescaping does not cover this context
  • Into the SQL parser — SQL injection
    • The ORM parameterises everything — SQL and values travel separately
    • .raw(sql, params) is safe too — the params list is what makes it safe, not the method
    • f-string · extra() · RawSQL() — the three shapes to grep for in review
  • Into your endpoint, from elsewhere — CSRF and clickjacking
    • CsrfViewMiddleware — a secret token on POST/PUT/PATCH/DELETE
    • X-Frame-Options: DENY — the default — your page cannot be framed
    • @csrf_exempt · @xframe_options_exempt — the two decorators worth a second reviewer

Command

python
Order.objects.raw("SELECT * FROM orders WHERE reference = %s", [reference])
# %s is a placeholder the driver fills in, NOT Python string formatting

All four attacks are untrusted input reaching an interpreter, and Django closes each boundary by default — so the vulnerability is almost always the line that opts out. Grep for those four shapes: `@csrf_exempt`, `|safe`/`mark_safe()`, `@xframe_options_exempt`, and an f-string inside `.raw()`/`extra()`/`RawSQL()`. Sanitise with an allow-list on write, so `|safe` lives in one auditable place. And quote every HTML attribute: autoescaping covers element content, not an unquoted attribute value.

This group is almost entirely settings, and almost all of them are off by default because Django cannot know your deployment. `ALLOWED_HOSTS` lists the hostnames your site answers to, so a forged `Host` header cannot make Django generate links pointing at an attacker. `SECURE_SSL_REDIRECT` sends HTTP to HTTPS. `SECURE_HSTS_SECONDS` tells browsers to refuse plain HTTP for this host from now on, which closes the gap the first redirect leaves open. Then the cookie flags: `Secure` means the cookie is only ever sent over HTTPS, `HttpOnly` means JavaScript cannot read it, and `SameSite` controls whether it is attached to cross-site requests at all. Django ships a checker for the lot — `manage.py check --deploy`.

Key features

  • `ALLOWED_HOSTS` is validated in `HttpRequest.get_host()`; code reading `request.META["HTTP_HOST"]` directly bypasses the check entirely
  • `SECURE_SSL_REDIRECT` handles requests after the first; HSTS is what protects the first, by making the browser refuse plain HTTP before it sends anything
  • `SECURE_HSTS_SECONDS` defaults to 0. Ramp it up (a few minutes, then a day, then a year) — a browser honours the last value it saw for that long, whatever you serve next
  • `SESSION_COOKIE_HTTPONLY` defaults to `True`; `CSRF_COOKIE_HTTPONLY` defaults to `False`, because client-side code often needs to read the CSRF token
  • `SESSION_COOKIE_SAMESITE` and `CSRF_COOKIE_SAMESITE` default to `"Lax"` — enough to stop cross-site POSTs while keeping ordinary inbound links working
  • `SECURE_PROXY_SSL_HEADER` makes Django trust a header from your proxy; the docs warn that setting it wrong can cause CSRF vulnerabilities, so the proxy must strip that header from client requests

The production settings, and what each one alone does not cover

The production settings, and what each one alone does not cover
SettingDefaultSet it toWhat it does NOT cover
`ALLOWED_HOSTS``[]`your real hostnamescode reading `HTTP_HOST` from `request.META`
`SECURE_SSL_REDIRECT``False``True`the very first plaintext request
`SECURE_HSTS_SECONDS``0`ramp to `31536000`a browser that has never visited you
`SESSION_COOKIE_SECURE``False``True`script reading the cookie (that is `HttpOnly`)
`CSRF_COOKIE_SECURE``False``True`the same
`SESSION_COOKIE_HTTPONLY``True`leave ita network observer (that is `Secure`)
`SESSION_COOKIE_SAMESITE``"Lax"``"Lax"`, or `"Strict"`anything same-site — it is not a CSRF replacement
`SECURE_PROXY_SSL_HEADER``None`only with a proxy that strips itnothing — read the warning first
Hardening a deployment, in the order the protections actually engage

1 · Decide who you are

ALLOWED_HOSTS makes a forged Host header a 400 instead of a link in a password-reset email pointing at the attacker.

2 · Get every later request onto TLS

The redirect covers request two onward. Request one already went in plaintext — that is the next step's job.

3 · Close the first-request gap with HSTS

The browser refuses plain HTTP for this host before sending anything. Ramp the value — it is honoured for its full duration whatever you serve later.

4 · Harden the cookies against three different attackers

Secure defeats a network observer, HttpOnly defeats injected script, SameSite defeats another site. None substitutes for the others.

5 · Let Django check your work

check --deploy names each remaining gap with an identifier. Run it against the production settings module, in CI.

  1. 1 · Decide who you are — ALLOWED_HOSTS makes a forged Host header a 400 instead of a link in a password-reset email pointing at the attacker.
  2. 2 · Get every later request onto TLS — The redirect covers request two onward. Request one already went in plaintext — that is the next step's job.
  3. 3 · Close the first-request gap with HSTS — The browser refuses plain HTTP for this host before sending anything. Ramp the value — it is honoured for its full duration whatever you serve later.
  4. 4 · Harden the cookies against three different attackers — Secure defeats a network observer, HttpOnly defeats injected script, SameSite defeats another site. None substitutes for the others.
  5. 5 · Let Django check your work — check --deploy names each remaining gap with an identifier. Run it against the production settings module, in CI.

Command

python
ALLOWED_HOSTS = ["api.example.com"]
SECURE_SSL_REDIRECT = True
SECURE_HSTS_SECONDS = 31536000
SESSION_COOKIE_SECURE = CSRF_COOKIE_SECURE = True

These are settings, they are off by default, and they cover different cases rather than overlapping. `ALLOWED_HOSTS` stops forged hosts from poisoning generated links — but only for code that goes through `get_host()`. `SECURE_SSL_REDIRECT` covers request two onward; HSTS covers request one, and is honoured by the browser for its full `max-age` no matter what you serve afterwards, so ramp it. Cookies need all three flags: `Secure` against the network, `HttpOnly` against script, `SameSite` against other sites. Run `check --deploy` in CI.

3.Passwords, sessions, SSRF, open redirects, and uploads

Django gets the identity half of this right on its own. Passwords are hashed with a salted, deliberately slow algorithm — PBKDF2 by default, with the iteration count raised every release — and upgraded transparently on the next login when you change hashers. Sessions rotate their key on login, so a session id an attacker planted beforehand becomes useless. Authorization you write yourself, and it belongs on the server for every request, never in the UI. The other three are the ones Django does *not* cover, because they involve your code taking user input and acting on it: **SSRF** (fetching a URL the user supplied), **open redirects** (redirecting to a URL the user supplied), and **file uploads** (storing and later serving bytes the user supplied). Each has one specific defence.

Key features

  • Django's default hasher is PBKDF2 with a per-password salt and an iteration count raised in each release; `check_password()` transparently re-hashes when the configured hasher changes
  • `login()` cycles the session key, which is what defeats session fixation; a session id planted before login does not survive it
  • SSRF: Django has no built-in protection. Validate the *resolved* IP against an allow-list, and block link-local (`169.254.0.0/16`, the cloud metadata address), loopback, and private ranges
  • Open redirects: `django.utils.http.url_has_allowed_host_and_scheme(url, allowed_hosts, require_https)` is the helper — it is what `LoginView`'s `next` parameter uses
  • Uploads: the client-supplied `content_type` and filename are both untrusted. Validate the extension against an allow-list, check the real content, cap the size, and generate the stored name yourself
  • Never serve user uploads from a path Django will execute or from your main domain — a stored HTML file served same-origin is stored XSS

Whose responsibility, and what the specific defence is

Whose responsibility, and what the specific defence is
RiskHandled byThe defence
Password storageDjangosalted PBKDF2, transparent upgrade on login; use `set_password()`
Session fixationDjango`login()` cycles the session key
Session theftyou (settings)`Secure`, `HttpOnly`, `SameSite` on the session cookie
Authorizationyoupermission checks in views/permission classes, never in templates alone
SSRF**you**resolve the host, allow-list the IP, block link-local/loopback/private
Open redirect**you**`url_has_allowed_host_and_scheme()` before every redirect to user input
Malicious upload**you**extension allow-list, content check, size cap, server-generated name, separate origin
Credential guessingyourate limit login/reset/signup, plus lockout keyed on the username
The three risks Django does not cover — user input becoming an instruction
uncheckeduncheckedunchecked

A value from the request

?next=… · {"url": …} · an uploaded file

Used as "send the user here"

open redirect

Used as "connect to this address"

SSRF — your server is inside a network the caller is not

Used as "store and serve these bytes"

upload

url_has_allowed_host_and_scheme()

allow-list the host and scheme before redirecting

Resolve, then allow-list the IP

block 169.254.169.254, loopback, private ranges — and re-check after redirects

Extension + content + size + your own filename

and serve from a separate origin that never executes

Your domain in a phishing link

Cloud credentials read from the metadata endpoint

Stored XSS, served same-origin

Proceed

  • A value from the request — ?next=… · {"url": …} · an uploaded file
    • leads to Used as "send the user here"
    • leads to Used as "connect to this address"
    • leads to Used as "store and serve these bytes"
  • Used as "send the user here" — open redirect
    • leads to url_has_allowed_host_and_scheme()
    • on error, leads to Your domain in a phishing link (unchecked)
  • Used as "connect to this address" — SSRF — your server is inside a network the caller is not
    • leads to Resolve, then allow-list the IP
    • on error, leads to Cloud credentials read from the metadata endpoint (unchecked)
  • Used as "store and serve these bytes" — upload
    • leads to Extension + content + size + your own filename
    • on error, leads to Stored XSS, served same-origin (unchecked)
  • url_has_allowed_host_and_scheme() — allow-list the host and scheme before redirecting
    • leads to Proceed
  • Resolve, then allow-list the IP — block 169.254.169.254, loopback, private ranges — and re-check after redirects
    • leads to Proceed
  • Extension + content + size + your own filename — and serve from a separate origin that never executes
    • leads to Proceed
  • Your domain in a phishing link
  • Cloud credentials read from the metadata endpoint
  • Stored XSS, served same-origin
  • Proceed

Command

python
from django.utils.http import url_has_allowed_host_and_scheme

if url_has_allowed_host_and_scheme(url, allowed_hosts={request.get_host()},
                                   require_https=request.is_secure()):
    return redirect(url)
return redirect("dashboard")

Django covers the identity half: salted PBKDF2 with transparent upgrades, and `login()` cycling the session key against fixation — as long as you use `set_password()` and set the cookie flags. The other half is yours, and all three cases are user input being used as an *instruction*. SSRF: resolve the host and allow-list the address, with redirects off, because a hostname the attacker controls can point anywhere. Open redirects: `url_has_allowed_host_and_scheme()` before any redirect to user input. Uploads: never trust the filename or content type — validate the real content, generate the name, and serve from a non-executing origin.

4.Dependencies, secrets, and check --deploy

Most of a Django project is code you did not write, and a vulnerability in any of it is a vulnerability in your application. Pin versions with a lock file so what you test is what you deploy, and run a scanner — `pip-audit` or Dependabot — in CI so a newly-published advisory fails a build rather than waiting for someone to read a mailing list. Secrets are the other half: `SECRET_KEY`, database passwords, and API keys belong in the environment or a secrets manager, never in the repository, and `SECRET_KEY_FALLBACKS` exists so you can rotate the key without invalidating every session at once. Then `python manage.py check --deploy` audits the settings themselves and names each gap with an identifier such as `security.W004` — run it in CI, against the production settings module.

Key features

  • A lock file (`poetry.lock`, `requirements.txt` with hashes, `uv.lock`) makes the deployed dependency set identical to the tested one
  • `pip-audit` checks installed packages against the Python advisory database; running it in CI turns a published advisory into a failing build
  • `SECRET_KEY` signs sessions, password-reset tokens, and every `signing`-module value; leaking it means an attacker can forge all of them
  • `SECRET_KEY_FALLBACKS` (Django 4.1+) holds previous keys so existing signatures stay valid during rotation — without it, rotating logs every user out
  • A secret committed once is compromised even after the commit is removed — git history keeps it, and forks and CI caches keep it too. Rotate rather than delete
  • `python manage.py check --deploy --settings=config.settings.production` reports each gap with an id (`security.W004`, `security.W008`, …) — run it in CI, not by hand

Where secrets live, and what each option actually gives you

Where secrets live, and what each option actually gives you
WhereRotatable?Visible inVerdict
Hard-coded in `settings.py`nothe repo, forevernever
A `.env` file committednothe repo, forevernever
A `.env` file, git-ignoredmanuallythe server, developer laptopsworkable for small deployments
Environment variables from the platformredeploythe process environment, `/proc`the common baseline
A secrets manager (Vault, AWS SM)yes, with auditthe manager, auditedthe right answer at scale
Rotating a leaked SECRET_KEY without logging everyone out
  1. T+0

    The key is found in git history

    Deleting the commit does not help — forks, clones, and CI caches all still have it. Treat it as compromised.

  2. T+5m

    Generate a new key

    get_random_secret_key() from django.core.management.utils. Store it in the secrets manager, never in the repo.

  3. T+10m

    Deploy with the old key as a fallback

    SECRET_KEY = new, SECRET_KEY_FALLBACKS = [old]. New signatures use the new key; existing sessions still verify.

  4. T+10m

    Nobody is logged out

    This is the entire point of the fallback list — rotation stops being an outage, so it stops being deferred.

  5. T+1 session lifetime

    Every live signature has been re-issued

    Sessions, password-reset tokens, and signed cookies minted before the rotation have expired naturally.

  6. T+1 week

    Remove the old key from the fallbacks

    Deploy again with SECRET_KEY_FALLBACKS empty. The leaked key now verifies nothing.

  7. Ongoing

    Add a secret scanner to CI

    The rotation fixed this key. The scanner is what stops the next one reaching a commit at all.

  1. T+0: The key is found in git history — Deleting the commit does not help — forks, clones, and CI caches all still have it. Treat it as compromised.
  2. T+5m: Generate a new key — get_random_secret_key() from django.core.management.utils. Store it in the secrets manager, never in the repo.
  3. T+10m: Deploy with the old key as a fallback — SECRET_KEY = new, SECRET_KEY_FALLBACKS = [old]. New signatures use the new key; existing sessions still verify.
  4. T+10m: Nobody is logged out — This is the entire point of the fallback list — rotation stops being an outage, so it stops being deferred.
  5. T+1 session lifetime: Every live signature has been re-issued — Sessions, password-reset tokens, and signed cookies minted before the rotation have expired naturally.
  6. T+1 week: Remove the old key from the fallbacks — Deploy again with SECRET_KEY_FALLBACKS empty. The leaked key now verifies nothing.
  7. Ongoing: Add a secret scanner to CI — The rotation fixed this key. The scanner is what stops the next one reaching a commit at all.

Command

bash
pip-audit --strict
python manage.py check --deploy --settings=config.settings.production

Dependencies decay without you touching them, so pin with a lock file and let `pip-audit` in CI turn a published advisory into a failing build. Keep secrets out of the repository and design for rotation from day one — `SECRET_KEY_FALLBACKS` is what makes rotating the signing key a deploy rather than a mass logout, and a secret committed once is compromised even after the commit is gone. Run `check --deploy` in CI against the *production* settings module: pointed at development settings it passes and means nothing.

Remember

  • Autoescaping covers element content, not an unquoted HTML attribute — always quote attributes, or an escaped value can still carry an event handler.
  • ALLOWED_HOSTS is enforced inside get_host(); code reading request.META["HTTP_HOST"] bypasses it, which is how a password-reset link ends up pointing at an attacker.
  • SSRF is defeated by resolving the hostname and allow-listing the address with redirects off — a block-list on the URL string loses to DNS.
  • An upload's filename and content type both come from the client: validate the real content, generate the stored name, and serve from a non-executing origin.

Pro tip

  • Set SECRET_KEY_FALLBACKS before you ever need it — a rotation that costs a mass logout is a rotation that gets postponed, on the one credential that forges everything else.
  • Ramp SECURE_HSTS_SECONDS from an hour upward: includeSubDomains applies to hostnames the browser has never visited, and there is no way to take it back from a browser that already saw it.

Next up

  • CSRF vs CORS
  • Caching
  • Observability

Sources

  • Security in Django — docs.djangoproject.com/en/5.2/topics/security/
  • Deployment checklist — docs.djangoproject.com/en/5.2/howto/deployment/checklist/
  • Every claim checked against Django 5.2 LTS docs · checked 2026-09-04

Spotted a mistake on this page? Tell us.