Skip to content
Secrets7 min read

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

Example. Kiteline is a fictional company.
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:

UseWith the key aloneWith 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 IDssame
JWTs from djangorestframework-simplejwt with default settingsforge access tokens for any user IDsame
Messages in cookiescraft messages the site accepts from a cookiesame
Sessions with the signed-cookie backendwrite any session datasign in as a user
Sessions with the database backendno new sessions; the cookie holds a random session IDsign in as a user
Password reset tokensnothing on its owncreate 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.

ChoiceUsersForged data
Leaked key in fallbacksstay signed instill accepted until you remove it
Clean cut, no fallbackseveryone signed out once; open reset links stop workingrejected 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 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.

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

Example. Kiteline is a fictional company.

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