Skip to content

SECRET_KEY

Severity: criticalApplies to: Flask 2.xApplies to: Flask 3.1.3Facts last verified 2026-08-13 against Flask 3.1.3 · itsdangerous 2.2.0

SECRET_KEY signs the session cookie. Whoever holds it can mint a cookie that Flask will accept as genuine — any user id, any role, no password involved.

That makes it the single highest-value string in a Flask application, and it is routinely the one committed to the repository, because the error below is annoying and the fastest way to make it stop is to type something.

settings on this page
SECRET_KEY

Signs the session cookie. None by default, and the failure is deferred rather than loud — the app starts fine and raises only when something first touches the session. Note what signing buys: the cookie cannot be forged, but it is not encrypted, so its contents base64-decode without this key.

accepts
any str or bytes
default
None
set in
app.config
Read in: Flask 3.1.3 app.config, read from a running app
SECRET_KEY_FALLBACKS

Old keys still accepted for verification but never used to sign. This is what makes key rotation survivable — without it, changing SECRET_KEY invalidates every session in flight at once.

accepts
a list of previous keys | None
default
None
set in
app.config
Read in: Flask 3.1.3 app.config; consumed at flask/sessions.py:309 — if fallbacks := app.config["SECRET_KEY_FALLBACKS"]
if you see this
RuntimeError: The session is unavailable because no secret key was set. Set the secret_key on the application to something unique and secret.

Raised on first write to the session, not at startup — so the application boots cleanly and the first request that touches a session is what fails. The doubled space after the first sentence is in the string itself, which matters if you are matching on it.

Read in: flask flask/sessions.py

The cookie is signed, not encrypted. Setting two values and reading the result:

session["user_id"] = 42
session["role"] = "admin"
session=eyJyb2xlIjoiYWRtaW4iLCJ1c2VyX2lkIjo0Mn0.amojyw.r4Ab1y20R6ejF7ChvR-BepH9WP8

The first segment decodes without any key at all:

{"role":"admin","user_id":42}

So the key stops a client changing the session. It does nothing to stop them reading it — which is a separate control covered on session cookies — and it is the whole of the tamper protection. There is no second factor.

the fix
import os
app.config["SECRET_KEY"] = os.environ["SECRET_KEY"] # KeyError if unset

os.environ[...] rather than os.getenv(...) is deliberate. os.getenv returns None when the variable is missing, and None is a perfectly usable signing key as far as itsdangerous is concerned — so the application starts, signs everything with None, and every deployment in the world shares that key.

Generate a real one:

Terminal window
python3 -c 'import secrets; print(secrets.token_hex(32))'
verify it workedRun this in: shell
Terminal window
# is a literal key in the source tree or its history?
git grep -nE "SECRET_KEY\s*=\s*['\"]" -- . || echo "no literal assignment found"
git log -p --all -S 'SECRET_KEY' -- . | grep -nE "^\+.*SECRET_KEY\s*=\s*['\"]" | head

The second command is the one that matters. Removing a key from the working tree leaves it in the history, and a repository that was ever public has to be treated as one where that value is known.

Check what the running process holds, and confirm it is not a placeholder:

Terminal window
python3 -c "from app import app; k=app.config['SECRET_KEY']; print(type(k), len(k or ''))"

None, dev, secret, changeme and anything short enough to type are all failures.

Changing the key invalidates every existing session at once. Every logged-in user is logged out, and any signed value you generate with the same key — password reset tokens, email confirmation links, itsdangerous payloads elsewhere in the application — stops verifying too.

That second part is what catches people: the blast radius is larger than “sessions” because SECRET_KEY is the default key for anything signed in the application.

Flask supports a rotation window through SECRET_KEY_FALLBACKS, which lets the new key sign while old keys still verify. That turns a hard cut into a staged one: add the new key, move the old key into the fallback list, deploy, wait longer than your session lifetime, then drop the fallback.

If the key has actually leaked, rotate immediately and accept the mass logout. The fallback list exists for planned rotation and would keep the leaked key valid.

Three other pages in this cluster end at this value, which is the argument for treating it as critical rather than high.

A template injection gives an attacker {{ config.SECRET_KEY }} directly — the whole application config is in the template context, so one SSTI is one round-trip from forging any session.

A debugger console reads it in one expression.

And any error page rendering app.config — a custom 500 template, a debug view, a health endpoint that dumps configuration — publishes it to whoever triggers the error.

before you ship this

Moving the key to an environment variable breaks every context that does not have that variable set, and those contexts are usually the ones nobody thinks of as deployments: CI, the test suite, a one-off management command, a flask shell on a jump box, and whatever runs your migrations.

With os.environ[...] those fail loudly at import, which is correct but abrupt. Set a throwaway value in the test configuration explicitly rather than reintroducing a fallback default:

conftest.py
os.environ.setdefault("SECRET_KEY", "testing-only-not-a-real-key")

Keeping it out of the application code is the point — a default in config.py is a default that ships.