Leaked Django SECRET_KEY: what it exposes and how to rotate
What someone can sign with your Django SECRET_KEY, why the fallback list works against you after a leak, and a rotation order that closes the gap.
Crunu teamPublished 7 min read
On this page
Replace it now, and think twice before putting the leaked key into SECRET_KEY_FALLBACKS. Django uses SECRET_KEY to sign data it later trusts: sessions, password reset tokens, cookie-stored messages and anything your code or your packages sign with it. With the key, someone can create values your application accepts as genuine; Django's own settings reference warns that a known key "defeats many of Django's security protections, and can lead to privilege escalation and remote code execution vulnerabilities." Rotation fixes that, and doesn't touch user passwords. The catch is the fallback list: Django validates signatures with every key in it, so a leaked key kept there for a smooth transition keeps accepting forged data for as long as it stays.
Django environment variables covers storing the key and the routine rotation. This post is about the case where it got out.
What the key signs
The settings reference lists the uses: all sessions unless you use the cache backend with a custom session auth hash, messages stored with CookieStorage or FallbackStorage, all PasswordResetView tokens, and "any usage of cryptographic signing, unless a different key is provided" (Django: settings). The signing documentation adds the reason it matters: the key must stay secure, "or attackers could use it to generate their own signed values" (Django: cryptographic signing).
What a leaked key allows depends on what else the attacker has. Reading Django's source gives a fairly precise answer:
| Use | With the key alone | With the key and a copy of the database |
|---|---|---|
Your own Signer, TimestampSigner, get_signed_cookie(), signing.dumps() | forge any value: unsubscribe links, invite tokens, signed IDs | same |
| JWTs from djangorestframework-simplejwt with default settings | forge access tokens for any user ID | same |
| Messages in cookies | craft messages the site accepts from a cookie | same |
| Sessions with the signed-cookie backend | write any session data | sign in as a user |
| Sessions with the database backend | no new sessions; the cookie holds a random session ID | sign in as a user |
| Password reset tokens | nothing on its own | create a valid reset link |
Two rows deserve a note. Simple JWT's SIGNING_KEY defaults to settings.SECRET_KEY, and its documentation recommends setting it to "a value that is independent from the django project secret key", precisely so that a compromise is easier to contain (Simple JWT: settings). If your API uses it with defaults, a leaked SECRET_KEY mints tokens.
The "database" column exists because Django mixes user state into some signatures. The session auth hash is an HMAC of the user's password field, and a reset token hashes "the user's primary key, email (if available), and some user state", including the password hash and last login time (Django source: tokens.py). The same file notes that the HMAC "prevents password cracking attempts using the reset token, provided the secret isn't compromised." Keys and database dumps can leak together, from the same backup or the same server.
The remote code execution in the warning depends on a specific setup: the sessions documentation describes it for pickle-serialized sessions with the signed-cookie backend, where a known key "immediately escalates to a remote code execution vulnerability." Current Django ships only a JSON serializer, so it applies only if you wrote or installed a pickle-based one.
The fallback list cuts both ways
For a planned rotation, SECRET_KEY_FALLBACKS is exactly right. It arrived in Django 4.1 as a setting for "secret key rotation" (Django 4.1 release notes), and the settings reference describes the routine: "set a new SECRET_KEY and move the previous value to the beginning of SECRET_KEY_FALLBACKS", then remove old values "when you are ready to expire the sessions, password reset tokens, and so on, that make use of them." Nobody is signed out.
For a leak, read the signing documentation's sentence carefully: fallback values "will not be used to sign data, but if specified, they will be used to validate signed data." Validation is the problem. A forged session, token or signed link made with the leaked key passes validation as long as that key is in the list.
| Choice | Users | Forged data |
|---|---|---|
| Leaked key in fallbacks | stay signed in | still accepted until you remove it |
| Clean cut, no fallbacks | everyone signed out once; open reset links stop working | rejected from the next deploy |
Choose the clean cut unless you have a concrete reason not to, and if you do keep a fallback, make it hours, not weeks. Signing everyone out is an inconvenience. Accepting forged signatures is the incident continuing.
Rotating after a leak
- Generate a new key on a trusted machine. Django's own helper does it:
python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())", or use the Django SECRET_KEY generator in your browser. - If anything else signs with the old key, such as Simple JWT, give it its own new key now rather than letting it follow
SECRET_KEYagain. - Replace the value wherever the application reads it, with an empty
SECRET_KEY_FALLBACKSfor a clean cut. - Restart every process that loaded the settings: web workers, Celery or RQ workers, scheduled jobs. A process that's still running keeps the old key in memory.
- Check that sign-in works, that a fresh password reset link works, and that an old session cookie is rejected.
- Run
python manage.py clearsessionsto remove expired session rows, and tell users they'll need to sign in again.
With Crunu, steps 3 and 4 are one change. The key lives in an environment such as django-app/production as DJANGO_SECRET_KEY. Paste the 50-character value from Django's helper rather than using the editor's Generate, which makes 40 characters, fewer than Django's deployment check (security.W009) expects. Saving creates a new signed version, and an app started with crunu run --env django-app/production --restart on-change -- gunicorn mysite.wsgi restarts with the new value. Start workers the same way so none is left behind.
If the database leaked too
A backup, a server snapshot or a copied .env can carry both the key and the database credentials. Then the right-hand column of the table applies, and rotating SECRET_KEY is only part of the work.
- Change the database password and any other credentials in the same file or snapshot. The rotation pages cover common providers.
- Assume password hashes are exposed. Rotation doesn't touch them, which is good for routine rotation and not enough here. Django's hashers are slow by design, but weak passwords fall to guessing. Ask users to change passwords, starting with staff and superusers.
- Invalidate sessions anyway. A clean cut already signs everyone out. If you kept a fallback, remove it now.
- Look for activity made with forged data. Sign-ins and password resets during the exposure window, especially for staff accounts, are where to start.
Work out where it leaked
Rotation closes the key. Finding the path tells you what else went with it.
- Git.
startprojectwrites a random key intosettings.py, which is easy to commit with the rest of the file.git log -S "SECRET_KEY" --onelinefinds commits that touched it. Anything else in that file, such as a database URL, leaked with it; a .env file leaked into Git covers the cleanup order. - Images. A real key passed in so
collectstaticcould run during a build stays in the image. Secrets in Docker images shows how to find it. - Error trackers and logs. Django's debug page hides settings whose names contain words such as
KEY,SECRETorTOKEN, but a custom setting name or a logged environment dump won't be hidden. - Chat and tickets. A pasted
.envlives as long as the message history.
In Crunu, the environment's history shows every version of django-app/production with its author, time and which fields changed, without values. That gives the incident a timeline: when the leaked value was set, and when it was replaced. Restoring an earlier version is possible from the same screen; for a leak, don't, since an earlier version contains the leaked key.
Keep it from happening again
- Read the key from the environment in
settings.py, never a literal:SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]. - Use a separate key per environment, so a leaked development key opens nothing in production.
- Give Simple JWT and any other signer their own keys.
- Use a throwaway value for build steps.
- Share keys through a vault rather than chat or a committed file; the guide to sharing .env files with a team covers the options.
The glossary entry on leaked secrets has the general playbook.
Sources
- Settings: SECRET_KEY and SECRET_KEY_FALLBACKS, Django documentation, checked October 30, 2026.
- Cryptographic signing, Django documentation, checked October 30, 2026.
- Django 4.1 release notes, checked October 30, 2026.
- How to use sessions, Django documentation, checked October 30, 2026.
- django/contrib/auth/tokens.py, Django source on GitHub, checked October 30, 2026.
- Settings, Simple JWT documentation, checked October 30, 2026.