Skip to content

Commit a20bcff

Browse files
authored
enable secret key rotation (#5632)
2 parents 7522c4b + e13373f commit a20bcff

File tree

9 files changed

+55
-7
lines changed

9 files changed

+55
-7
lines changed

CHANGES.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ Unreleased
2020
- ``-e path`` takes precedence over default ``.env`` and ``.flaskenv`` files.
2121
``load_dotenv`` loads default files in addition to a path unless
2222
``load_defaults=False`` is passed. :issue:`5628`
23+
- Support key rotation with the ``SECRET_KEY_FALLBACKS`` config, a list of old
24+
secret keys that can still be used for unsigning. Extensions will need to
25+
add support. :issue:`5621`
2326

2427

2528
Version 3.0.3

docs/config.rst

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,22 @@ The following configuration values are used internally by Flask:
125125

126126
Default: ``None``
127127

128+
.. py:data:: SECRET_KEY_FALLBACKS
129+
130+
A list of old secret keys that can still be used for unsigning, most recent
131+
first. This allows a project to implement key rotation without invalidating
132+
active sessions or other recently-signed secrets.
133+
134+
Keys should be removed after an appropriate period of time, as checking each
135+
additional key adds some overhead.
136+
137+
Flask's built-in secure cookie session supports this. Extensions that use
138+
:data:`SECRET_KEY` may not support this yet.
139+
140+
Default: ``None``
141+
142+
.. versionadded:: 3.1
143+
128144
.. py:data:: SESSION_COOKIE_NAME
129145
130146
The name of the session cookie. Can be changed in case you already have a

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ source = ["src", "*/site-packages"]
7979

8080
[tool.mypy]
8181
python_version = "3.9"
82-
files = ["src/flask", "tests/typing"]
82+
files = ["src/flask", "tests/type_check"]
8383
show_error_codes = true
8484
pretty = true
8585
strict = true
@@ -95,7 +95,7 @@ ignore_missing_imports = true
9595

9696
[tool.pyright]
9797
pythonVersion = "3.9"
98-
include = ["src/flask", "tests/typing"]
98+
include = ["src/flask", "tests/type_check"]
9999
typeCheckingMode = "basic"
100100

101101
[tool.ruff]

src/flask/app.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ class Flask(App):
180180
"TESTING": False,
181181
"PROPAGATE_EXCEPTIONS": None,
182182
"SECRET_KEY": None,
183+
"SECRET_KEY_FALLBACKS": None,
183184
"PERMANENT_SESSION_LIFETIME": timedelta(days=31),
184185
"USE_X_SENDFILE": False,
185186
"SERVER_NAME": None,

src/flask/sessions.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -315,14 +315,20 @@ class SecureCookieSessionInterface(SessionInterface):
315315
def get_signing_serializer(self, app: Flask) -> URLSafeTimedSerializer | None:
316316
if not app.secret_key:
317317
return None
318-
signer_kwargs = dict(
319-
key_derivation=self.key_derivation, digest_method=self.digest_method
320-
)
318+
319+
keys: list[str | bytes] = [app.secret_key]
320+
321+
if fallbacks := app.config["SECRET_KEY_FALLBACKS"]:
322+
keys.extend(fallbacks)
323+
321324
return URLSafeTimedSerializer(
322-
app.secret_key,
325+
keys, # type: ignore[arg-type]
323326
salt=self.salt,
324327
serializer=self.serializer,
325-
signer_kwargs=signer_kwargs,
328+
signer_kwargs={
329+
"key_derivation": self.key_derivation,
330+
"digest_method": self.digest_method,
331+
},
326332
)
327333

328334
def open_session(self, app: Flask, request: Request) -> SecureCookieSession | None:

tests/test_basic.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import gc
22
import re
3+
import typing as t
34
import uuid
45
import warnings
56
import weakref
@@ -369,6 +370,27 @@ def expect_exception(f, *args, **kwargs):
369370
expect_exception(flask.session.pop, "foo")
370371

371372

373+
def test_session_secret_key_fallbacks(app, client) -> None:
374+
@app.post("/")
375+
def set_session() -> str:
376+
flask.session["a"] = 1
377+
return ""
378+
379+
@app.get("/")
380+
def get_session() -> dict[str, t.Any]:
381+
return dict(flask.session)
382+
383+
# Set session with initial secret key
384+
client.post()
385+
assert client.get().json == {"a": 1}
386+
# Change secret key, session can't be loaded and appears empty
387+
app.secret_key = "new test key"
388+
assert client.get().json == {}
389+
# Add initial secret key as fallback, session can be loaded
390+
app.config["SECRET_KEY_FALLBACKS"] = ["test key"]
391+
assert client.get().json == {"a": 1}
392+
393+
372394
def test_session_expiration(app, client):
373395
permanent = True
374396

File renamed without changes.
File renamed without changes.
File renamed without changes.

0 commit comments

Comments
 (0)