SECRET_KEY
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.
SECRET_KEYSigns 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
SECRET_KEY_FALLBACKSOld 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
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.pyWhat the signature actually protects
Section titled “What the signature actually protects”The cookie is signed, not encrypted. Setting two values and reading the result:
session["user_id"] = 42session["role"] = "admin"session=eyJyb2xlIjoiYWRtaW4iLCJ1c2VyX2lkIjo0Mn0.amojyw.r4Ab1y20R6ejF7ChvR-BepH9WP8The 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.
import os
app.config["SECRET_KEY"] = os.environ["SECRET_KEY"] # KeyError if unsetos.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:
python3 -c 'import secrets; print(secrets.token_hex(32))'# 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*['\"]" | headThe 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:
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.
Rotating it, and why people do not
Section titled “Rotating it, and why people do not”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.
The blast radius is bigger than sessions
Section titled “The blast radius is bigger than sessions”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.
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:
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.
Related
Section titled “Related”- Session cookies — the flags on the cookie this key signs
- Template injection — the shortest path from a bug to this value