Flask vulnerable to possible disclosure of permanent session cookie due to missing Vary: Cookie header
Description
Flask is a lightweight WSGI web application framework. When all of the following conditions are met, a response containing data intended for one client may be cached and subsequently sent by the proxy to other clients. If the proxy also caches Set-Cookie headers, it may send one client's session cookie to other clients. The severity depends on the application's use of the session and the proxy's behavior regarding cookies. The risk depends on all these conditions being met.
- The application must be hosted behind a caching proxy that does not strip cookies or ignore responses with cookies.
- The application sets
session.permanent = True - The application does not access or modify the session at any point during a request.
SESSION_REFRESH_EACH_REQUESTenabled (the default).- The application does not set a
Cache-Controlheader to indicate that a page is private or should not be cached.
This happens because vulnerable versions of Flask only set the Vary: Cookie header when the session is accessed or modified, not when it is refreshed (re-sent to update the expiration) without being accessed or modified. This issue has been fixed in versions 2.3.2 and 2.2.5.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Under specific caching-proxy conditions, Flask incorrectly omits `Vary: Cookie` headers, enabling session cookie leakage between users.
Vulnerability
Overview
CVE-2023-30861 is a cache-poisoning vulnerability in Flask, the popular Python WSGI web framework. The root cause lies in the save_session() method, which only added a Vary: Cookie response header when the session was explicitly accessed or modified, but not when the session was simply refreshed (i.e., re-sent to update its expiration timestamp without being read or written) [1][2]. This breaks the HTTP caching contract, because the Vary header is the standard way to tell proxies that a response differs based on the request's Cookie header.
Exploitation
Conditions
Exploitation requires a specific configuration: the application must be deployed behind a caching proxy that does not strip or ignore Set-Cookie headers, the session must be configured as permanent (session.permanent = True), and the SESSION_REFRESH_EACH_REQUEST setting must remain enabled (the default) [1]. Additionally, the application must not touch the session during a request and must not set an explicit Cache-Control: private or no-cache header. Under these circumstances, the proxy may cache a response that carries a Set-Cookie header without also receiving a Vary: Cookie directive, causing the cached cookie to be served to other clients [1][3].
Impact
A successfully exploited cache-poisoning attack can result in one user's session cookie being delivered to another user by the intermediary proxy. The severity is application-dependent: if the session contains sensitive data or grants access to privileged functions, this could lead to session hijacking or unauthorized access to another user's account [1][4].
Mitigation
The Flask project fixed the issue in versions 2.3.2 and 2.2.5 by modifying the save_session() method to unconditionally add the Vary: Cookie header whenever a session cookie is set — regardless of whether the session was accessed, modified, or merely refreshed [2][3]. Users should upgrade to one of the patched versions. For those unable to upgrade, reviewing proxy configuration to strip or ignore Set-Cookie headers, or manually setting Cache-Control: private on relevant responses, can serve as a workaround [1].
AI Insight generated on May 20, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
FlaskPyPI | >= 2.3.0, < 2.3.2 | 2.3.2 |
FlaskPyPI | < 2.2.5 | 2.2.5 |
Affected products
23- ghsa-coords22 versionspkg:pypi/flaskpkg:rpm/opensuse/python-Flask&distro=openSUSE%20Leap%2015.4pkg:rpm/opensuse/python-Flask&distro=openSUSE%20Leap%2015.5pkg:rpm/suse/python-Flask&distro=SUSE%20Enterprise%20Storage%207pkg:rpm/suse/python-Flask&distro=SUSE%20Enterprise%20Storage%207.1pkg:rpm/suse/python-Flask&distro=SUSE%20Linux%20Enterprise%20High%20Performance%20Computing%2015%20SP1-LTSSpkg:rpm/suse/python-Flask&distro=SUSE%20Linux%20Enterprise%20High%20Performance%20Computing%2015%20SP2-LTSSpkg:rpm/suse/python-Flask&distro=SUSE%20Linux%20Enterprise%20High%20Performance%20Computing%2015%20SP3-ESPOSpkg:rpm/suse/python-Flask&distro=SUSE%20Linux%20Enterprise%20High%20Performance%20Computing%2015%20SP3-LTSSpkg:rpm/suse/python-Flask&distro=SUSE%20Linux%20Enterprise%20Module%20for%20Basesystem%2015%20SP4pkg:rpm/suse/python-Flask&distro=SUSE%20Linux%20Enterprise%20Module%20for%20Basesystem%2015%20SP5pkg:rpm/suse/python-Flask&distro=SUSE%20Linux%20Enterprise%20Module%20for%20Package%20Hub%2015%20SP4pkg:rpm/suse/python-Flask&distro=SUSE%20Linux%20Enterprise%20Module%20for%20Package%20Hub%2015%20SP5pkg:rpm/suse/python-Flask&distro=SUSE%20Linux%20Enterprise%20Real%20Time%2015%20SP3pkg:rpm/suse/python-Flask&distro=SUSE%20Linux%20Enterprise%20Server%2015%20SP1-LTSSpkg:rpm/suse/python-Flask&distro=SUSE%20Linux%20Enterprise%20Server%2015%20SP2-LTSSpkg:rpm/suse/python-Flask&distro=SUSE%20Linux%20Enterprise%20Server%2015%20SP3-LTSSpkg:rpm/suse/python-Flask&distro=SUSE%20Linux%20Enterprise%20Server%20for%20SAP%20Applications%2015%20SP1pkg:rpm/suse/python-Flask&distro=SUSE%20Linux%20Enterprise%20Server%20for%20SAP%20Applications%2015%20SP2pkg:rpm/suse/python-Flask&distro=SUSE%20Linux%20Enterprise%20Server%20for%20SAP%20Applications%2015%20SP3pkg:rpm/suse/python-Flask&distro=SUSE%20Manager%20Proxy%204.2pkg:rpm/suse/python-Flask&distro=SUSE%20Manager%20Server%204.2
>= 2.3.0, < 2.3.2+ 21 more
- (no CPE)range: >= 2.3.0, < 2.3.2
- (no CPE)range: < 1.0.4-150400.3.3.1
- (no CPE)range: < 1.0.4-150400.3.3.1
- (no CPE)range: < 1.0.2-150100.6.3.1
- (no CPE)range: < 1.0.2-150100.6.3.1
- (no CPE)range: < 1.0.2-150100.6.3.1
- (no CPE)range: < 1.0.2-150100.6.3.1
- (no CPE)range: < 1.0.2-150100.6.3.1
- (no CPE)range: < 1.0.2-150100.6.3.1
- (no CPE)range: < 1.0.4-150400.3.3.1
- (no CPE)range: < 1.0.4-150400.3.3.1
- (no CPE)range: < 1.0.2-150100.6.3.1
- (no CPE)range: < 1.0.2-150100.6.3.1
- (no CPE)range: < 1.0.2-150100.6.3.1
- (no CPE)range: < 1.0.2-150100.6.3.1
- (no CPE)range: < 1.0.2-150100.6.3.1
- (no CPE)range: < 1.0.2-150100.6.3.1
- (no CPE)range: < 1.0.2-150100.6.3.1
- (no CPE)range: < 1.0.2-150100.6.3.1
- (no CPE)range: < 1.0.2-150100.6.3.1
- (no CPE)range: < 1.0.2-150100.6.3.1
- (no CPE)range: < 1.0.2-150100.6.3.1
Patches
2afd63b16170bMerge pull request #5109 from pallets/backport-vary-cookie
3 files changed · +30 −4
CHANGES.rst+1 −0 modified@@ -4,6 +4,7 @@ Version 2.2.5 Unreleased - Update for compatibility with Werkzeug 2.3.3. +- Set ``Vary: Cookie`` header when the session is accessed, modified, or refreshed. Version 2.2.4
src/flask/sessions.py+6 −4 modified@@ -383,6 +383,10 @@ def save_session( samesite = self.get_cookie_samesite(app) httponly = self.get_cookie_httponly(app) + # Add a "Vary: Cookie" header if the session was accessed at all. + if session.accessed: + response.vary.add("Cookie") + # If the session is modified to be empty, remove the cookie. # If the session is empty, return without setting the cookie. if not session: @@ -395,13 +399,10 @@ def save_session( samesite=samesite, httponly=httponly, ) + response.vary.add("Cookie") return - # Add a "Vary: Cookie" header if the session was accessed at all. - if session.accessed: - response.vary.add("Cookie") - if not self.should_set_cookie(app, session): return @@ -417,3 +418,4 @@ def save_session( secure=secure, samesite=samesite, ) + response.vary.add("Cookie")
tests/test_basic.py+23 −0 modified@@ -560,6 +560,11 @@ def getitem(): def setdefault(): return flask.session.setdefault("test", "default") + @app.route("/clear") + def clear(): + flask.session.clear() + return "" + @app.route("/vary-cookie-header-set") def vary_cookie_header_set(): response = flask.Response() @@ -592,11 +597,29 @@ def expect(path, header_value="Cookie"): expect("/get") expect("/getitem") expect("/setdefault") + expect("/clear") expect("/vary-cookie-header-set") expect("/vary-header-set", "Accept-Encoding, Accept-Language, Cookie") expect("/no-vary-header", None) +def test_session_refresh_vary(app, client): + @app.get("/login") + def login(): + flask.session["user_id"] = 1 + flask.session.permanent = True + return "" + + @app.get("/ignored") + def ignored(): + return "" + + rv = client.get("/login") + assert rv.headers["Vary"] == "Cookie" + rv = client.get("/ignored") + assert rv.headers["Vary"] == "Cookie" + + def test_flashes(app, req_ctx): assert not flask.session.modified flask.flash("Zap")
70f906c51ce4Merge pull request from GHSA-m2qf-hxjv-5gpq
2 files changed · +29 −4
src/flask/sessions.py+6 −4 modified@@ -329,6 +329,10 @@ def save_session( samesite = self.get_cookie_samesite(app) httponly = self.get_cookie_httponly(app) + # Add a "Vary: Cookie" header if the session was accessed at all. + if session.accessed: + response.vary.add("Cookie") + # If the session is modified to be empty, remove the cookie. # If the session is empty, return without setting the cookie. if not session: @@ -341,13 +345,10 @@ def save_session( samesite=samesite, httponly=httponly, ) + response.vary.add("Cookie") return - # Add a "Vary: Cookie" header if the session was accessed at all. - if session.accessed: - response.vary.add("Cookie") - if not self.should_set_cookie(app, session): return @@ -363,3 +364,4 @@ def save_session( secure=secure, samesite=samesite, ) + response.vary.add("Cookie")
tests/test_basic.py+23 −0 modified@@ -501,6 +501,11 @@ def getitem(): def setdefault(): return flask.session.setdefault("test", "default") + @app.route("/clear") + def clear(): + flask.session.clear() + return "" + @app.route("/vary-cookie-header-set") def vary_cookie_header_set(): response = flask.Response() @@ -533,11 +538,29 @@ def expect(path, header_value="Cookie"): expect("/get") expect("/getitem") expect("/setdefault") + expect("/clear") expect("/vary-cookie-header-set") expect("/vary-header-set", "Accept-Encoding, Accept-Language, Cookie") expect("/no-vary-header", None) +def test_session_refresh_vary(app, client): + @app.get("/login") + def login(): + flask.session["user_id"] = 1 + flask.session.permanent = True + return "" + + @app.get("/ignored") + def ignored(): + return "" + + rv = client.get("/login") + assert rv.headers["Vary"] == "Cookie" + rv = client.get("/ignored") + assert rv.headers["Vary"] == "Cookie" + + def test_flashes(app, req_ctx): assert not flask.session.modified flask.flash("Zap")
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
12- github.com/advisories/GHSA-m2qf-hxjv-5gpqghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2023-30861ghsaADVISORY
- github.com/pallets/flask/commit/70f906c51ce49c485f1d355703e9cc3386b1cc2bghsax_refsource_MISCWEB
- github.com/pallets/flask/commit/afd63b16170b7c047f5758eb910c416511e9c965ghsax_refsource_MISCWEB
- github.com/pallets/flask/releases/tag/2.2.5ghsax_refsource_MISCWEB
- github.com/pallets/flask/releases/tag/2.3.2ghsax_refsource_MISCWEB
- github.com/pallets/flask/security/advisories/GHSA-m2qf-hxjv-5gpqghsax_refsource_CONFIRMWEB
- github.com/pypa/advisory-database/tree/main/vulns/flask/PYSEC-2023-62.yamlghsaWEB
- lists.debian.org/debian-lts-announce/2023/08/msg00024.htmlghsaWEB
- security.netapp.com/advisory/ntap-20230818-0006ghsaWEB
- www.debian.org/security/2023/dsa-5442ghsaWEB
- security.netapp.com/advisory/ntap-20230818-0006/mitre
News mentions
0No linked articles in our index yet.