Get listed

Django cryptography: SECRET_KEY, hashers, and signing

A wax-sealed letter with a coral secret key left on the blotter.

Django’s crypto helpers are for signing cookies and resetting passwords, not for inventing your own protocol.

If you need to hash a password, use the hasher Django already ships. If you need to encrypt a field at rest, use a library that manages nonces and keys. SECRET_KEY is a signing secret. It is not a justification for rolling AES by hand in a view.

The usual mistake is copying a Stack Overflow snippet that uses a static IV or a home-grown token format, then blaming Django when the token leaks.

This page is which Django crypto APIs to use, which settings must be set in production, and the cases where you should stop and call a real KMS.

Django 5.2.17 and 6.0.8 shipped on 4 August 2026. security weblog. The settings page is blunter than the CVEs: running with a known SECRET_KEY defeats many of Django’s protections and can become privilege escalation or code execution.Use the key Django already uses to sign sessions, the hasher that stores passwords, and django.core.signing.

This is not a primer on block ciphers. Keep session management next to this page for the cookie model. Read Flask locks for the same key-and-hatch shape on Pallets. If someone wants a JWT instead of Django’s session, read JWT in Express before you add a third token. The secure coding checklist is the rest of secret handling.

A known SECRET_KEY is already a break

5.2 SECRET_KEY page. Default in the setting itself is an empty string. Django refuses to start if it is unset. startproject writes a random value into settings.py. The development template prefixes that value with django-insecure- so you can grep it. The prefix is a warning, not a hasher.

5.2 is the LTS that shipped on 2 April 2025. 5.1 lost security fixes in December 2025. 5.0 was already done. If the app is still on 5.1 in August 2026, the key talk below does not replace an upgrade to 5.2.17 or 6.0.8.

import os
from django.core.exceptions import ImproperlyConfigured

def load_secret_key() -> str:
 key = os.environ.get("DJANGO_SECRET_KEY", "")
 if not key:
 raise ImproperlyConfigured("missing DJANGO_SECRET_KEY")
 if key.startswith("django-insecure-"):
 raise ImproperlyConfigured("DJANGO_SECRET_KEY is still the startproject value")
 if len(key) < 50:
 raise ImproperlyConfigured("DJANGO_SECRET_KEY is too short")
 return key

SECRET_KEY = load_secret_key()

load_secret_key is the named gate. Put the real value in the host environment. Do not commit it. Do not reuse it as a database password. The 5.2 page lists what the key signs: sessions, password-reset tokens, messages if you use cookie storage, and anything you pass through the signing API. A leaked key lets a stranger mint those values. It does not let them read a password hash. Those are different stores.

Django’s deploy check security.W009 fires on manage.py check --deploy when the key is under 50 characters, has fewer than 5 distinct characters, or starts with django-insecure-. security.W025 is the same test on each fallback. Those are warnings, not boot failures. load_secret_key fails the process before a request. A CI job that runs check without --deploy will never see W009.

Mint a replacement with the same module Flask documents:

python -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())'

Or secrets.token_urlsafe(50) if Django is not on sys.path yet. Both are fine. A 12-character passphrase is not.

Rotate with SECRET_KEY_FALLBACKS

SECRET_KEY_FALLBACKS shipped in Django 4.1. Default: empty list. same settings page. New signatures use SECRET_KEY only. Old signatures may still verify against the fallback list. That is how you rotate without dropping every live session on deploy.

def fallback_keys() -> list[str]:
 raw = os.environ.get("DJANGO_SECRET_KEY_FALLBACKS", "")
 return [item for item in raw.split(",") if item]

SECRET_KEY_FALLBACKS = fallback_keys()

fallback_keys is the named helper. Put the previous SECRET_KEY in that env var, deploy, wait at least SESSION_COOKIE_AGE plus the longest signed link you issue, then clear the var. The docs warn that each extra fallback is extra verify work. Do not keep a graveyard of ten old keys. A compromised key is not a slow rotate. Replace SECRET_KEY, skip the fallback, and accept a mass logout.

Password hashes do not use this key. Rotating it will not force a password reset. That is the sentence on the settings page. Reset tokens and signed cookies will fail once the old value leaves both settings.

Three stores. One key signs. Fallbacks only verify. Hashers never see the key.
SIGN SECRET_KEY
 sessions, reset links, signing.dumps
 new MAC only

VERIFY SECRET_KEY then SECRET_KEY_FALLBACKS
 old cookies still unsign
 drop the fallback after SESSION_COOKIE_AGE

STORE PASSWORD_HASHERS[0]
 PBKDF2 1_000_000 or Argon2id
 check_password upgrades on login

PASSWORD_HASHERS: one million, then Argon2

The 5.2 password page and the 5.2 release notes are the first-party pair. The stored string is algorithm$iterations$salt$hash. Django 5.2 raised the default PBKDF2 work factor from 870,000 to 1,000,000. New hashes use the first entry in PASSWORD_HASHERS. The default first entry is still PBKDF2PasswordHasher.

PASSWORD_HASHERS = [
 "django.contrib.auth.hashers.PBKDF2PasswordHasher",
 "django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher",
 "django.contrib.auth.hashers.Argon2PasswordHasher",
 "django.contrib.auth.hashers.BCryptSHA256PasswordHasher",
 "django.contrib.auth.hashers.ScryptPasswordHasher",
]

Leave that list alone unless you are changing the first entry on purpose. Removing a name means a user whose stored algorithm is gone cannot log in and cannot upgrade. check_password rewrites the stored string when the work factor or the first hasher changed. That rewrite happens on a successful login. It does not happen in a management command unless you write one.

Argon2id is what the 2015 Password Hashing Competition recommended. Django does not default to it because it needs argon2-cffi. The first-party install line is python -m pip install django[argon2]. Then put Argon2PasswordHasher first and keep PBKDF2 in the list so old rows still verify:

PASSWORD_HASHERS = [
 "django.contrib.auth.hashers.Argon2PasswordHasher",
 "django.contrib.auth.hashers.PBKDF2PasswordHasher",
 "django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher",
 "django.contrib.auth.hashers.BCryptSHA256PasswordHasher",
 "django.contrib.auth.hashers.ScryptPasswordHasher",
]

Do not invent a custom hashlib.sha256(password) hasher. Do not store a recoverable password. Encryption of a password is the wrong tool. A one-way hasher is the product. I will not walk Fernet on a User.password column.

The same password page opens with TLS. A hasher on disk does not help if the login POST is cleartext. Set the site on HTTPS, turn on SECURE_SSL_REDIRECT, and set SESSION_COOKIE_SECURE and CSRF_COOKIE_SECURE once the terminator is real. Those flags are cookie transport. They are not a substitute for load_secret_key. The session guide on this site is the longer cookie argument.

To raise PBKDF2 above 1,000,000 without switching algorithms, subclass the hasher and set iterations, then put your subclass first. I only do that after a login-time measurement on the hardware I deploy. A laptop number will lie. Users with old hashes upgrade on the next successful check_password. Tell support that the first login after a work-factor change is slower. That is the upgrade, not a bug.

signing.dumps is a MAC, not a vault

The 5.2 signing page is the API this URL should have taught. Signer appends a signature. TimestampSigner also appends a time. signing.dumps and signing.loads are shortcuts around TimestampSigner with the salt django.core.signing. The payload is JSON. The docs say that on purpose: even if the key leaks, the loader will not run pickle opcodes.

from django.core import signing

RESET_SALT = "account.reset"

def mint_reset_link(user_id: int) -> str:
 return signing.dumps({"uid": user_id}, salt=RESET_SALT, compress=False)

def read_reset_link(token: str) -> int:
 data = signing.loads(token, salt=RESET_SALT, max_age=3600)
 return int(data["uid"])

mint_reset_link and read_reset_link are the named pair. RESET_SALT is a namespace, not a secret. A token minted for reset cannot satisfy a different salt. Catch signing.BadSignature and signing.SignatureExpired. Fail closed. Do not swap in a custom pickle serializer. The JSON default is the control. max_age accepts seconds or a timedelta. One hour is the value above. Password-reset mail that lives for a week is a product choice you should be able to name in the ticket.

Signed values are readable. Anyone who sees the cookie or the query can read the JSON. Do not put a password, a raw reset secret, or a role you cannot show in that payload. Put an id. Reload authority on the server. That is the same rule as a Flask client session.

Mint reset tokens with secrets

Signing is one shape. A random opaque token is the other. Use secrets when the value is a capability you will look up in the database, not a self-contained signed blob.

import hashlib
import secrets
from django.utils import timezone

def issue_opaque_reset(user) -> str:
 raw = secrets.token_urlsafe(32)
 user.reset_token_hash = hashlib.sha256(raw.encode("ascii")).hexdigest()
 user.reset_token_at = timezone.now()
 user.save(update_fields=["reset_token_hash", "reset_token_at"])
 return raw

issue_opaque_reset is the named helper. Send raw once. Store the hash. Compare with secrets.compare_digest. This is how you revoke a single link without rotating SECRET_KEY. Django’s built-in password-reset flow already signs. Use that flow unless you have a reason to store your own row. Do not use random.randint for the token. Do not put the raw value back in the user table.

Prove the key, the hasher, and the signer

You are not walking an exploit. You are proving the process refuses a missing key, the hasher list still contains every algorithm you have stored, and a bad signature fails closed.

# boot must fail without the key
env -u DJANGO_SECRET_KEY python manage.py check
# Expect: ImproperlyConfigured missing DJANGO_SECRET_KEY

python manage.py shell -c "from django.conf import settings; print(settings.PASSWORD_HASHERS[0]); print(len(settings.SECRET_KEY) >= 50)"
rg -n "SECRET_KEY\\s*=\\s*['\\\"]|django-insecure-|signing\\.loads\\(.*pickle|hashlib\\.sha256\\(.*password|Fernet\\(|django_cryptography" \
 --glob '!venv' --glob '!.venv'

A literal key, a django-insecure- prefix in a prod settings module, or a sha256 of the password is a review. A hit on django_cryptography is not automatically wrong. It is a field-encryption choice that still needs load_secret_key. If you already wrap columns, give that wrapper its own env var. Reusing SECRET_KEY for field encryption means a session-key rotate also rewrites every ciphertext, or silently fails decrypt. Split the keys.

Run manage.py check --deploy in CI and fail the job on warnings you have not waived in a file you own. W009 and W025 are the two that match this page. A waived W009 is a ticket, not a default. I want that job on the same change that adds load_secret_key.

Questions we keep getting

Does rotating SECRET_KEY lock everyone out?

Yes, if you change it and leave SECRET_KEY_FALLBACKS empty. Sessions and signed links fail. Password hashes do not. Use fallback_keys for a planned rotate. Skip it if the old value leaked.

Is a signed cookie encrypted?

No. The client can read it. The MAC stops modification if SECRET_KEY is random and unlisted. Store an id. Reload secrets on the server.

Should I encrypt address columns with Fernet?

Maybe, as a product control after a dump, with a key that is not SECRET_KEY. That is not what this URL is for anymore. Fix SECRET_KEY, PASSWORD_HASHERS, and signing.dumps first. A column wrapper on top of a tutorial key is theatre.