# 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.

URL: https://crunu.com/blog/leaked-django-secret-key
Language: en
Updated: 2026-10-30

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](https://crunu.com/learn/key-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](https://crunu.com/environment-variables/django) 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](https://docs.djangoproject.com/en/stable/ref/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](https://docs.djangoproject.com/en/stable/topics/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](https://django-rest-framework-simplejwt.readthedocs.io/en/latest/settings.html)). 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](https://github.com/django/django/blob/main/django/contrib/auth/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](https://docs.djangoproject.com/en/stable/topics/http/sessions/) 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](https://docs.djangoproject.com/en/4.1/releases/4.1/)), 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

1. 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](https://crunu.com/tools/secret-generator/django-secret-key) in your browser.
2. 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_KEY` again.
3. Replace the value wherever the application reads it, with an empty `SECRET_KEY_FALLBACKS` for a clean cut.
4. 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.
5. Check that sign-in works, that a fresh password reset link works, and that an old session cookie is rejected.
6. Run `python manage.py clearsessions` to remove expired session rows, and tell users they'll need to sign in again.

*Figure: A new variable row in the payments-api production table. The name CHECKOUT_SIGNING_KEY is typed, and the Generate button has just filled the value with a random 40-character string of letters and digits, so nobody has to invent a secret. Add and Cancel finish the row.*

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](https://crunu.com/rotate/aws) 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.** `startproject` writes a random key into `settings.py`, which is easy to commit with the rest of the file. `git log -S "SECRET_KEY" --oneline` finds commits that touched it. Anything else in that file, such as a database URL, leaked with it; [a .env file leaked into Git](https://crunu.com/blog/env-file-leaked-in-git) covers the cleanup order.
- **Images.** A real key passed in so `collectstatic` could run during a build stays in the image. [Secrets in Docker images](https://crunu.com/blog/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`, `SECRET` or `TOKEN`, but a custom setting name or a logged environment dump won't be hidden.
- **Chat and tickets.** A pasted `.env` lives 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.

*Figure: The history of the payments-api production environment at Kiteline, a fictional company: versions 15 to 11, each with its author, time and device, and what changed by variable name, never by value. Version 15 is current. Version 13 is open, and a dialog asks to restore it: Crunu will save a new version 16 with the files of version 13, and version 15 stays in the history.*

## 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](https://crunu.com/guides/share-env-files-with-a-team) covers the options.

The glossary entry on [leaked secrets](https://crunu.com/learn/leaked-secret) has the general playbook.

## Sources

- [Settings: SECRET_KEY and SECRET_KEY_FALLBACKS, Django documentation](https://docs.djangoproject.com/en/stable/ref/settings/), checked October 30, 2026.
- [Cryptographic signing, Django documentation](https://docs.djangoproject.com/en/stable/topics/signing/), checked October 30, 2026.
- [Django 4.1 release notes](https://docs.djangoproject.com/en/4.1/releases/4.1/), checked October 30, 2026.
- [How to use sessions, Django documentation](https://docs.djangoproject.com/en/stable/topics/http/sessions/), checked October 30, 2026.
- [django/contrib/auth/tokens.py, Django source on GitHub](https://github.com/django/django/blob/main/django/contrib/auth/tokens.py), checked October 30, 2026.
- [Settings, Simple JWT documentation](https://django-rest-framework-simplejwt.readthedocs.io/en/latest/settings.html), checked October 30, 2026.
