Filter concepts by levelShowing all levels.

Django · Section 56

Django Security

Level
advanced
Read
38 min
Concepts
4

Django defends against CSRF, XSS, SQL injection and clickjacking by default, so a vulnerability in one of those is almost never the framework — it is the line that opted out, and each has a recognisable shape: `@csrf_exempt`, `|safe`/`mark_safe()`, `@xframe_options_exempt`, and an f-string inside `.raw()`/`extra()`/`RawSQL()`. One real gap in the defaults is worth memorising because it looks safe: autoescaping covers element content, not an *unquoted* HTML attribute. The transport and cookie settings are a different category — they are off by default because Django cannot know your hostnames, your proxy, or whether you have TLS — and they cover different cases rather than overlapping: `SECURE_SSL_REDIRECT` protects the second request onward, HSTS protects the first, and the cookie flags defend against three different attackers (`Secure` the network, `HttpOnly` injected script, `SameSite` another site). HSTS in particular has no server-side undo, so it is ramped rather than switched on. Django then covers the identity primitives — salted PBKDF2 with transparent upgrades, and `login()` cycling the session key against fixation — but not the three risks where your code takes user input and acts on it: SSRF (resolve the host, allow-list the address, redirects off), open redirects (`url_has_allowed_host_and_scheme()`), and uploads (validate the real content, generate the stored name, serve from a non-executing origin). Finally the operational layer, where the failure is omission rather than a bug: pin dependencies and audit them in CI, keep secrets out of the repository and rotatable via `SECRET_KEY_FALLBACKS`, and run `manage.py check --deploy` against the production settings module.

What is true here

  1. Django closes the four classic injection boundaries by default; the vulnerability is the line that opts out.
  2. Autoescaping does not cover an unquoted HTML attribute — quote every attribute.
  3. The production settings are off by default and cover distinct cases: redirect, HSTS, and three cookie flags.
  4. SSRF, open redirects, and file uploads are yours — all three are user input used as an instruction.
  5. check --deploy, pip-audit, and SECRET_KEY_FALLBACKS turn security from something remembered into something enforced.

What you will be able to do

  • Review a Django codebase by looking for the four lines that disable a built-in defence
  • Write a production settings module that passes `check --deploy`, and understand what each setting alone does not cover
  • Ramp HSTS safely, and explain why it has no server-side undo
  • Defend a user-supplied URL fetch, a user-supplied redirect, and a user-supplied file upload
  • Make dependency auditing, secret rotation, and the deployment check part of CI rather than of memory
Defence in depth — what each layer stops, and who is responsible for it

Browser

HSTS refuses plain HTTP before a request is sent · SameSite withholds cookies cross-site · X-Frame-Options blocks framing

Transport

TLS everywhere, SECURE_SSL_REDIRECT for request two onward — yours to configure, off by default

Django middleware

ALLOWED_HOSTS validates the Host · CsrfViewMiddleware requires the token · XFrameOptionsMiddleware sends DENY

View and permission layer

Authorization on every request, rate limits on the credential endpoints, and no authorization decided in a template

Your code at the boundaries

SSRF, open redirects, uploads — the three Django does not cover, because they are user input used as an instruction

ORM and templates

Parameterised SQL and autoescaped output — correct by default, until an f-string or a mark_safe() steps around them

Supply chain and configuration

pip-audit on a lock file, secrets outside the repo and rotatable, check --deploy in CI

  1. Browser — HSTS refuses plain HTTP before a request is sent · SameSite withholds cookies cross-site · X-Frame-Options blocks framing
  2. Transport — TLS everywhere, SECURE_SSL_REDIRECT for request two onward — yours to configure, off by default
  3. Django middleware — ALLOWED_HOSTS validates the Host · CsrfViewMiddleware requires the token · XFrameOptionsMiddleware sends DENY
  4. View and permission layer — Authorization on every request, rate limits on the credential endpoints, and no authorization decided in a template
  5. Your code at the boundaries — SSRF, open redirects, uploads — the three Django does not cover, because they are user input used as an instruction
  6. ORM and templates — Parameterised SQL and autoescaped output — correct by default, until an f-string or a mark_safe() steps around them
  7. Supply chain and configuration — pip-audit on a lock file, secrets outside the repo and rotatable, check --deploy in CI

The four Django already handles

CSRF, XSS, SQL injection and clickjacking — and the exact line that turns each defence off.

CSRF, XSS, SQL injection, and clickjacking

coreintermediate

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.

Think of it as

All four are the same shape: untrusted input crossing into a context where it can be *interpreted* rather than merely stored. SQL injection is user text reaching the SQL parser; XSS is user text reaching the HTML parser; CSRF is an attacker's page reaching your endpoint with the user's ambient credentials attached; clickjacking is your page reaching a context the user cannot see. Django handles the boundary correctly in each case, so vulnerabilities almost never come from the framework — they come from a line that steps around it, and each one is a recognisable shape you can grep for. That reframes review usefully: rather than auditing for "is this safe", look for the four opt-outs by name — `mark_safe`, `|safe`, `csrf_exempt`, and an f-string inside a `raw()`/`extra()`. There is one gap where the defaults genuinely do not cover you, and it is worth remembering because it looks safe: autoescaping escapes for *element content*, and an unquoted HTML attribute is a different context. `<div class={{ value }}>` with a value containing a space and an event handler executes, and Django's own documentation gives this exact example. Quoting the attribute fixes it, which is why "always quote attributes" belongs in the same mental slot as the other three rules.

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

What we're doing: Render user-supplied rich text without handing the page over to whoever wrote it.

comments/models.pypython
import bleach

ALLOWED_TAGS = ["p", "br", "strong", "em", "a", "ul", "ol", "li", "code"]
ALLOWED_ATTRS = {"a": ["href", "title"]}
ALLOWED_PROTOCOLS = ["http", "https", "mailto"]


class Comment(models.Model):
    body_raw = models.TextField()               # exactly what the user typed
    body_html = models.TextField(blank=True)    # sanitised, safe to render

    def save(self, *args, **kwargs):
        self.body_html = bleach.clean(
            markdown(self.body_raw),
            tags=ALLOWED_TAGS,
            attributes=ALLOWED_ATTRS,
            protocols=ALLOWED_PROTOCOLS,
            strip=True,
        )
        super().save(*args, **kwargs)

# comment.html — |safe is justified only because body_html was sanitised on write:
#   <div class="comment">{{ comment.body_html|safe }}</div>
#   <a href="{{ comment.author_url }}" title="{{ comment.author }}">   <- attributes quoted
3–5
Three allow-lists, not a block-list. Enumerating what is permitted is the only approach that survives a new HTML feature or a novel encoding trick.
9–10
Storing both the original and the sanitised version means the sanitiser can be re-run after an upgrade, and the user's own text is never destroyed.
13–19
Sanitising on write rather than on render: it happens once per comment instead of once per page view, and there is exactly one place where the safe value is produced.
21–24
The `|safe` is the whole point of the exercise, and it is defensible only because of the twelve lines above it. Note the quoted attributes on the anchor — autoescaping does not protect an unquoted one.

Why this works: Sanitising once on write and marking the stored result safe keeps `|safe` to a single, auditable place — as opposed to `mark_safe()` scattered through templates, where each occurrence is a separate judgement nobody re-checks.

Reaching for `mark_safe()` to make formatting work

Wrong

python
def rendered_body(self):
    return mark_safe(markdown(self.body_raw))   # markdown passes raw HTML straight through

Better

python
def rendered_body(self):
    return mark_safe(bleach.clean(markdown(self.body_raw), tags=ALLOWED_TAGS, strip=True))

What you see: Nothing breaks — bold and links render correctly, which is exactly why it ships. A comment containing an `onerror` handler on an image tag then executes for every reader of that page, in their session.

Why: Markdown renderers pass embedded HTML through by design, so `markdown(user_text)` is still user-controlled HTML. `mark_safe()` does not inspect anything; it sets a flag telling the template not to escape. Combining the two means the user chooses what the template renders. Sanitising with an allow-list before marking safe is what makes the flag truthful.

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

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

Together

python
Order.objects.raw("SELECT * FROM orders WHERE reference = %s", [reference])   # safe
Order.objects.raw(f"SELECT * FROM orders WHERE reference = '{reference}'")   # injectable

Remember: 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.

See also: host transport and cookie hardening · identity and server side request safety · autoescaping and safe strings · raw sql escape hatches

Advertisement

Host, transport, and cookies

The production settings that are off by default, and what each one alone does not cover.

Advertisement

Identity, and the three Django does not cover

Passwords and sessions, then SSRF, open redirects, and uploads — user input used as an instruction.

Passwords, sessions, SSRF, open redirects, and uploads

coreadvanced

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.

Think of it as

Split the seven items by who is responsible. Password hashing, session key rotation, and the shape of the auth framework are Django's, and the failure mode there is configuration — a weak hasher, a session cookie without `Secure`, an authorization check written into a template instead of a view. The other three are yours, and they share one shape: your server takes a value from a request and uses it as an *instruction* rather than as data. In SSRF the instruction is "connect to this address", and the reason it is dangerous is that your server sits inside a network the caller does not — cloud metadata endpoints, internal admin panels, databases — so "fetch this URL for me" is a request to use your network position. In an open redirect the instruction is "send the user here", which launders your domain's credibility into a phishing link. In an upload the instruction is "store these bytes and serve them back", where the danger is the serving. All three are fixed the same way: an allow-list of what is permitted, checked after any resolution or normalisation, rather than a block-list of what is not.

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")

What we're doing: Fetch a user-supplied URL — an avatar import, a webhook test — without letting the caller borrow your network position.

integrations/fetch.pypython
import ipaddress, socket, requests

BLOCKED = [ipaddress.ip_network(n) for n in (
    "127.0.0.0/8", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
    "169.254.0.0/16",      # link-local — includes 169.254.169.254, cloud metadata
    "::1/128", "fc00::/7", "fe80::/10",
)]


def safe_fetch(url, *, max_bytes=2 * 1024 * 1024, timeout=5):
    parsed = urlparse(url)
    if parsed.scheme not in ("http", "https"):
        raise ValidationError("Only http and https URLs are allowed.")

    infos = socket.getaddrinfo(parsed.hostname, None)
    for info in infos:
        addr = ipaddress.ip_address(info[4][0])
        if any(addr in net for net in BLOCKED) or addr.is_reserved:
            raise ValidationError("That address is not reachable from here.")

    response = requests.get(
        url,
        timeout=timeout,
        allow_redirects=False,      # a redirect would escape the check above
        stream=True,
    )
    response.raise_for_status()

    body = response.raw.read(max_bytes + 1)
    if len(body) > max_bytes:
        raise ValidationError("That file is too large.")
    return body
3–7
`169.254.169.254` is the cloud metadata endpoint on AWS, GCP and Azure. An unguarded fetcher will happily read instance credentials from it and return them in a response body.
12
Scheme allow-list first. Without it `file:///etc/passwd` and `gopher://` are on the table, depending on the client library.
15–19
Checking every resolved address, not just the hostname. A hostname the attacker controls can resolve to an internal address, which is why the check has to happen after resolution.
24
`allow_redirects=False` is essential: a public URL that 302s to `169.254.169.254` would otherwise walk straight past the check. To follow redirects, re-run the validation on each hop.
29–31
A size cap read from the stream. Without it, a URL pointing at an endless response holds a worker and its memory indefinitely.

Why this works: Every line here exists because the naive version — `requests.get(url)` — is a request to use your server's network position, and the interesting targets are all things only your server can reach.

Validating the URL string instead of the resolved address

Wrong

python
if "localhost" in url or "127.0.0.1" in url or "169.254" in url:
    raise ValidationError("Not allowed.")
return requests.get(url).content

Better

python
return safe_fetch(url)     # resolves the hostname, checks every address, no redirects

What you see: The block-list passes every test written against it, and an attacker submits `http://metadata.attacker-domain.com/`, a hostname they control that resolves to `169.254.169.254`. The string check sees nothing wrong.

Why: A URL string and the address it reaches are different things, and the attacker controls the mapping between them via DNS. Block-lists also lose to encodings — decimal IPs, IPv6-mapped addresses, redirects — because they enumerate what is forbidden while the attacker enumerates what was forgotten. Resolving first and allow-listing the resulting address checks the thing that actually matters: where the socket will connect.

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

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

Together

python
if not url_has_allowed_host_and_scheme(next_url, allowed_hosts={request.get_host()},
                                       require_https=request.is_secure()):
    next_url = reverse("dashboard")

Remember: 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.

See also: host transport and cookie hardening · dependencies secrets and deployment checks · upload validation as untrusted input · passwords staff and permissions

Advertisement

Dependencies, secrets, and the deployment check

The operational layer, where the failure is an omission and CI is the only reliable defence.

Dependencies, secrets, and check --deploy

coreintermediate

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.

Think of it as

These three are the operational layer of security: not "is this code correct" but "will this stay correct next month". Dependencies decay on their own — you can change nothing and become vulnerable, because the vulnerability was published rather than introduced. That makes the schedule the mechanism: a scanner in CI turns "someone should check" into a failing build with a date on it, and a lock file means the version that failed the scan is the version that would have shipped. Secrets fail differently, through leakage rather than decay, and the leaks are boring — a key committed once and still in git history, a value pasted into a log line, a `.env` baked into a Docker image. Assume every secret will eventually need replacing and design for rotation from the start, because a key you cannot rotate without a mass logout is a key you will not rotate. `check --deploy` then covers the gap between the other two: settings that are individually correct in development and wrong in production, which no linter and no test will catch, because they are only wrong in an environment your tests never run in.

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

What we're doing: Make CI refuse to ship an insecure deployment: vulnerable dependencies, a missing setting, or a committed secret.

.github/workflows/ci.ymlyaml
- name: Install exactly what will be deployed
  run: pip install --require-hashes -r requirements.txt

- name: Audit dependencies against the advisory database
  run: pip-audit --strict

- name: Audit the production settings
  run: python manage.py check --deploy --settings=config.settings.production
  env:
    DJANGO_SECRET_KEY: ${{ secrets.CI_DUMMY_SECRET_KEY }}
    ALLOWED_HOSTS: example.com

- name: Scan the diff for committed secrets
  run: gitleaks protect --staged --redact
2
`--require-hashes` means a dependency that changed content since the lock file was written fails the install, which closes the gap between "the version we tested" and "the artifact we fetched".
5
`--strict` makes the job fail rather than merely report, which is the difference between a scanner and a newsletter.
8–11
Running the check against the *production* settings module, with a dummy key. Running it against development settings reports nothing useful and gives false confidence.
14
Scanning the staged diff catches a secret before it is committed. After the commit, the only remedy left is rotation.

Why this works: Each step converts a thing someone was supposed to remember into a thing the build enforces — which matters most for exactly this category, where the failure is an omission rather than a bug.

Running `check --deploy` against the development settings

Wrong

bash
python manage.py check --deploy
# Uses DJANGO_SETTINGS_MODULE=config.settings.dev — DEBUG=True, SQLite, no HTTPS.
# System check identified no issues (0 silenced).

Better

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

What you see: The check passes cleanly in CI and the production deployment is missing HSTS, secure cookies, and a real `ALLOWED_HOSTS`. The green tick is worse than no check, because it was read as evidence.

Why: `check --deploy` inspects whatever settings module is loaded, and the whole point of the check is the settings that differ between environments. Pointed at development settings it reports on values nobody deploys. Passing `--settings` explicitly — and supplying the environment variables that module reads — is what makes the result mean anything.

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.

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

Together

python
SECRET_KEY = env("DJANGO_SECRET_KEY")
SECRET_KEY_FALLBACKS = env.list("DJANGO_SECRET_KEY_FALLBACKS", default=[])

Remember: 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.

See also: host transport and cookie hardening · identity and server side request safety · configuration strategy

Advertisement