Security context
LowGHSA-4grg-w6v8-c28g CVE-2025-47278CWE-683Published May 13, 2025

Flask uses fallback key instead of current signing key

Research this vulnerability

Research is free — Hunters explains how the bug works, the root-cause code pattern, how the fix addresses it, and how to test whether a target is affected, in chat. Investigate & write exploit is a paid run — the engine reads the advisory and fix commits, then builds and validates a working proof-of-concept exploit with reproduction steps.

Affected versions

3.1.0 → fixed in 3.1.1

Details

In Flask 3.1.0, the way fallback key configuration was handled resulted in the last fallback key being used for signing, rather than the current signing key. Signing is provided by the `itsdangerous` library. A list of keys can be passed, and it expects the last (top) key in the list to be the most recent key, and uses that for signing. Flask was incorrectly constructing that list in reverse, passing the signing key first. Sites that have opted-in to use key rotation by setting `SECRET_KEY_FALLBACKS` are likely to unexpectedly be signing their sessions with stale keys, and their transition to fresher keys will be impeded. Sessions are still signed, so this would not cause any sort of data integrity loss.

The fix

secret key rotation: fix key list ordering

James Addison· Mar 10, 2025, 04:34 PM+218fb54159861
CHANGES.rst+2 0
@@ -3,6 +3,8 @@ Version 3.1.1
Unreleased
+- Fix signing key selection order when key rotation is enabled via
+ ``SECRET_KEY_FALLBACKS``. :ghsa:`4grg-w6v8-c28g`
- Fix type hint for `cli_runner.invoke`. :issue:`5645`
- ``flask --help`` loads the app and plugins first to make sure all commands
are shown. :issue:5673`
src/flask/sessions.py+2 1
@@ -318,11 +318,12 @@ def get_signing_serializer(self, app: Flask) -> URLSafeTimedSerializer | None:
if not app.secret_key:
return None
- keys: list[str | bytes] = [app.secret_key]
+ keys: list[str | bytes] = []
if fallbacks := app.config["SECRET_KEY_FALLBACKS"]:
keys.extend(fallbacks)
+ keys.append(app.secret_key) # itsdangerous expects current key at top
return URLSafeTimedSerializer(
keys, # type: ignore[arg-type]
salt=self.salt,
tests/test_basic.py+11 4
@@ -381,14 +381,21 @@ def set_session() -> str:
def get_session() -> dict[str, t.Any]:
return dict(flask.session)
- # Set session with initial secret key
+ # Set session with initial secret key, and two valid expiring keys
+ app.secret_key, app.config["SECRET_KEY_FALLBACKS"] = (
+ "0 key",
+ ["-1 key", "-2 key"],
+ )
client.post()
assert client.get().json == {"a": 1}
# Change secret key, session can't be loaded and appears empty
- app.secret_key = "new test key"
+ app.secret_key = "? key"
assert client.get().json == {}
- # Add initial secret key as fallback, session can be loaded
- app.config["SECRET_KEY_FALLBACKS"] = ["test key"]
+ # Rotate the valid keys, session can be loaded
+ app.secret_key, app.config["SECRET_KEY_FALLBACKS"] = (
+ "+1 key",
+ ["0 key", "-1 key"],
+ )
assert client.get().json == {"a": 1}
docs/config.rst | 9 ++++++---
1 file changed, 6 insertions(+), 3 deletions(-)
docs/config.rst+6 3
@@ -127,13 +127,16 @@ The following configuration values are used internally by Flask:
.. py:data:: SECRET_KEY_FALLBACKS
- A list of old secret keys that can still be used for unsigning, most recent
- first. This allows a project to implement key rotation without invalidating
- active sessions or other recently-signed secrets.
+ A list of old secret keys that can still be used for unsigning. This allows
+ a project to implement key rotation without invalidating active sessions or
+ other recently-signed secrets.
Keys should be removed after an appropriate period of time, as checking each
additional key adds some overhead.
+ Order should not matter, but the default implementation will test the last
+ key in the list first, so it might make sense to order oldest to newest.
+
Flask's built-in secure cookie session supports this. Extensions that use
:data:`SECRET_KEY` may not support this yet.

References