Software Securityhome
Fix a findingSearch
Fix card · A07:2025 Authentication Failures

Fix session fixation and session cookies in Python and Flask

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

With server-side sessions (Flask-Session), call app.session_interface.regenerate(session) after storing the user at sign-in; session.clear() keeps the same ID. Set SESSION_COOKIE_NAME = "__Host-session", SESSION_COOKIE_SECURE = True, SESSION_COOKIE_SAMESITE = "Lax" and PERMANENT_SESSION_LIFETIME to the idle limit, and end sessions 8 hours after sign-in in a before_request hook.

On this page

Steps

  1. In the login view, after checking the password, read what you need from the anonymous session (a return URL), call session.clear(), store the user and the sign-in time, then call current_app.session_interface.regenerate(session). It deletes the old ID from the store and gives the session a new one.
  2. Call regenerate() after storing the user, not right after session.clear(): Flask-Session 0.8.0's regenerate() does nothing while the session is empty, and with the two lines swapped this card's fixation tests fail.
  3. Regenerate again whenever privileges change, and on sign-out call session.clear(), which makes Flask-Session delete the stored session and the cookie.
  4. Set SESSION_COOKIE_NAME = "__Host-session", SESSION_COOKIE_SECURE = True, SESSION_COOKIE_HTTPONLY = True (the default) and SESSION_COOKIE_SAMESITE = "Lax", and leave SESSION_COOKIE_DOMAIN unset and the cookie path at / so browsers accept the __Host- prefix.
  5. Set PERMANENT_SESSION_LIFETIME to the idle limit (30 minutes here) and keep SESSION_PERMANENT and SESSION_REFRESH_EACH_REQUEST on (their defaults), so every request renews the stored session's time to live and the store forgets an idle one.
  6. Store the sign-in time in the session and clear the session in a before_request hook once it is older than the absolute limit (8 hours here); nothing in Flask or Flask-Session ends a busy session.
  7. Add regression tests that replay the pre-login cookie after sign-in, read the Set-Cookie attributes, and move a test clock past both limits; with the cachelib backend, point cachelib.simple's time() at the test clock so the store expires sessions too.

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerableimpl/vulnerable.pyPython
def sign_in(username: str, now: float) -> str:
    # Mark the visitor's session as signed in. Its ID stays the same.
    session["user"] = username
    return session.get("return_to", "/account")

With IMPL=vulnerable, 7 of 11 tests fail (7 exploit tests).

The fix: a new session ID at sign-in

Fixedimpl/fixed.pyPython
def sign_in(username: str, now: float) -> str:
    return_to = session.get("return_to", "/account")
    session.clear()  # nothing from the anonymous session carries over
    session["user"] = username
    session["signed_in_at"] = now
    # A new session ID: regenerate() deletes the old ID from the store. It does nothing while the
    # session is empty, so call it after storing the user.
    current_app.session_interface.regenerate(session)
    return return_to

With IMPL=fixed, 11 of 11 tests pass.

The fix: cookie attributes and timeouts

Fixedimpl/fixed.pyPython
IDLE_TIMEOUT = timedelta(minutes=30)
ABSOLUTE_TIMEOUT = timedelta(hours=8)


def configure_session(app: Flask, clock: Callable[[], float]) -> None:
    app.config.update(
        SESSION_TYPE="cachelib",
        SESSION_CACHELIB=SimpleCache(),  # one process only: use Redis or a database in production
        SESSION_COOKIE_NAME="__Host-session",  # browsers accept it only with Secure, Path=/, no Domain
        SESSION_COOKIE_SECURE=True,
        SESSION_COOKIE_HTTPONLY=True,
        SESSION_COOKIE_SAMESITE="Lax",
        # Every request re-saves the session (SESSION_REFRESH_EACH_REQUEST, on by default), so the
        # store forgets it this long after the last request: the idle timeout.
        SESSION_PERMANENT=True,
        PERMANENT_SESSION_LIFETIME=IDLE_TIMEOUT,
    )
    Session(app)

    @app.before_request
    def end_session_at_absolute_timeout() -> None:
        # Nothing in Flask or Flask-Session ends a busy session, so end it 8 hours after sign-in.
        signed_in_at = session.get("signed_in_at")
        if signed_in_at is not None and clock() - signed_in_at >= ABSOLUTE_TIMEOUT.total_seconds():
            session.clear()  # Flask-Session deletes the stored session and the cookie

With IMPL=fixed, 11 of 11 tests pass.

A fix that looks safe but is not

Wrong fiximpl/wrong.pyPython
def sign_in(username: str, now: float) -> str:
    return_to = session.get("return_to", "/account")
    # As in the Flask tutorial: clear the session, then store the user. session.clear() empties
    # the server-side session but keeps its ID.
    session.clear()
    session["user"] = username
    session["signed_in_at"] = now
    return return_to

With IMPL=wrong, 2 of 11 tests fail (2 exploit tests).

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Testtests/test_session.pyPython
# Exploit: a session ID that outlives sign-in, cookie attributes that expose the ID, and
# sessions that never end.


def test_exploit_session_id_issued_before_sign_in_does_not_work_after_the_victim_signs_in(browser):
    # The attacker opens the site and is given a session ID without signing in...
    attacker = browser()
    attacker.send("GET", "/account", TYPED_URL)
    attacker.send("GET", "/login", SAME_ORIGIN_PAGE)
    # ...and the victim signs in on a browser that already holds it: a shared computer the
    # attacker used first, or a cookie written from a sibling subdomain (a __Host- cookie can't be).
    victim = sign_in(attacker.clone(), "alice")
    assert signed_in_as(victim) == "alice"
    assert signed_in_as(attacker) != "alice", "the pre-login session ID opens Alice's account"


def test_exploit_attackers_own_session_planted_in_the_victims_browser_does_not_become_the_victims(signed_in):
    bob = signed_in("bob")
    victim = sign_in(bob.clone(), "alice")  # Bob plants his signed-in session cookie in Alice's browser
    assert signed_in_as(victim) == "alice"
    assert signed_in_as(bob) != "alice", "Bob's session now opens Alice's account"


def test_exploit_session_cookie_is_httponly_so_page_scripts_cannot_read_it(signed_in):
    name, line, attributes = session_cookie(signed_in("alice"))
    assert "httponly" in attributes, f"no HttpOnly in Set-Cookie: {line}"


def test_exploit_session_cookie_is_secure_so_the_browser_never_sends_it_over_plain_http(signed_in):
    name, line, attributes = session_cookie(signed_in("alice"))
    assert "secure" in attributes, f"no Secure in Set-Cookie: {line}"


def test_exploit_session_cookie_is_samesite_lax_or_strict(signed_in):
    name, line, attributes = session_cookie(signed_in("alice"))
    assert re.fullmatch(r"lax|strict", attributes.get("samesite", ""), re.IGNORECASE), f"Set-Cookie: {line}"


def test_exploit_session_cookie_has_the_host_prefix_path_slash_and_no_domain(signed_in):
    name, line, attributes = session_cookie(signed_in("alice"))
    assert name.startswith("__Host-"), f"the session cookie is called {name}"
    assert "secure" in attributes and attributes.get("path") == "/" and "domain" not in attributes, f"Set-Cookie: {line}"


def test_exploit_session_left_idle_for_31_minutes_no_longer_works(signed_in, clock):
    alice = signed_in("alice")
    clock.advance(TIMELINE["idleFor"])
    assert signed_in_as(alice) is None, f"the session still works after {TIMELINE['idleFor']} idle minutes"


def test_exploit_session_kept_busy_stops_working_8_hours_after_sign_in(signed_in, clock):
    alice = signed_in("alice")
    for t in range(TIMELINE["pollEvery"], TIMELINE["busyUntil"] + 1, TIMELINE["pollEvery"]):
        clock.advance(TIMELINE["pollEvery"])
        assert signed_in_as(alice) == "alice", f"signed out after {t} busy minutes"
    clock.advance(TIMELINE["pastAbsoluteLimit"] - TIMELINE["busyUntil"])
    assert signed_in_as(alice) is None, f"the session still works {TIMELINE['pastAbsoluteLimit']} minutes after sign-in"

Pitfalls

  • Flask's tutorial signs users in with session.clear() followed by session['user_id'] = ..., described as storing the id in a new session. That is true of Flask's default signed-cookie session, whose whole content is replaced. With Flask-Session the data lives on the server under the session ID in the cookie, and clear() keeps that ID.
  • Fixation needs a server-side session ID. Flask's default cookie session has none to fix, but a copied cookie keeps working until PERMANENT_SESSION_LIFETIME runs out (31 days by default), even after sign-out, because the server cannot revoke it.
  • Flask-Session makes every session permanent by default (SESSION_PERMANENT = True), so with PERMANENT_SESSION_LIFETIME left at 31 days a session survives a month of inactivity.
  • SESSION_TYPE has no working default: Flask-Session 0.8.0 raises ValueError for its default 'null'. cachelib's SimpleCache, used here, holds sessions in one process; use Redis or a database in production.
  • Flask sets HttpOnly by default but neither Secure nor SameSite; SESSION_COOKIE_DOMAIN must stay unset for a __Host- cookie (since Flask 2.3 it no longer falls back to SERVER_NAME).
  • Behind a TLS-terminating proxy, Secure is still set because Flask writes it from configuration, not from the request; but request.is_secure and url_for(_external=True) need Werkzeug's ProxyFix.
  • No scanner checked here reads SESSION_COOKIE_* settings or Flask-Session calls, so keep the regression tests.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs session-cookies/python-flask does this for you).
  2. Against your own app, save the cookie a visitor gets before signing in (curl -c jar -b jar https://your.app/account), sign in with the same jar, and check that the sign-in response sets a new session cookie and that the old value no longer opens the account page; check Secure, HttpOnly, SameSite and the name in that Set-Cookie line.
  3. Search the code for session.clear() in login views and for SESSION_COOKIE_ settings: neither Semgrep, CodeQL nor Bandit reports a missing regenerate() or a missing cookie attribute here.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix session fixation and session cookies in Python and Flask
ToolRuleWhat it found here
Semgreppython.flask.security.audit.secure-set-cookie.secure-set-cookieNo findings on any implementation. The rule checks response.set_cookie(...) calls on responses built with flask.make_response or flask.Response; the session cookie is written by Flask-Session from the SESSION_COOKIE_* settings, and no rule in semgrep-rules (develop at 9bbf021) checks those settings or session fixation in Flask.Checked: Semgrep 1.180.0 (CE)
CodeQLpy/insecure-cookieNot expected to report anything: python/ql/lib/semmle/python/frameworks/Flask.qll models cookie writes as calls to set_cookie on a Flask response, not the SESSION_COOKIE_* configuration or a session interface such as Flask-Session.Checked: github/codeql main at b5b8165; not executed
CodeQLpy/client-exposed-cookieNot expected to report anything, for the same reason; py/samesite-none-cookie likewise looks only at set_cookie calls, and only for SameSite=None.Checked: github/codeql main at b5b8165; not executed
BanditnoneNo Bandit test covers session cookies or session fixationNo findings on any implementation.Checked: Bandit 1.9.4

Libraries and versions

Libraries and versions
ComponentVersion
LanguagePython
RuntimeCPython 3.14.8
FrameworkFlask 3.1.3 with Flask-Session 0.8.0 (server-side sessions)
flask3.1.3
flask-session0.8.0
cachelib0.17.0
msgspec0.21.1
werkzeug3.1.9
pytest9.1.1

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the session-cookies/python-flaskfolder.

Shell
python3.14 -m venv .venv
.venv/bin/python -m pip install --require-hashes -r requirements.txt
Shell
# Expected: every test passes
IMPL=fixed .venv/bin/python -m pytest

# Expected: an exploit test fails
IMPL=vulnerable .venv/bin/python -m pytest

# Expected: a test fails
IMPL=wrong .venv/bin/python -m pytest

Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs session-cookies/python-flask runs all three modes and checks the expected results.

Download the project ZIP, 29 KB

Verified

Machine
macOS 27.0 (Darwin 27.0.0, arm64)
Toolchain
  • python 3.14.8
  • pip 26.2.1
  • flask 3.1.3
  • flaskSession 0.8.0
  • cachelib 0.17.0
  • werkzeug 3.1.9
  • pytest 9.1.1
Matrix
fixed: 11 of 11 tests pass; vulnerable: 7 of 11 tests fail (7 exploit tests); wrong: 2 of 11 tests fail (2 exploit tests)
Test results
fixed 11/11 pass; vulnerable 7/11 fail (7 exploit tests: both fixation tests, Secure, SameSite, __Host-, idle and absolute timeouts; the HttpOnly test passes because Flask sets it by default); wrong 2/11 fail (both fixation tests), offline after install
Dependency freshness
requirements.txt pins all 16 packages with sha256 hashes for every file PyPI publishes. Flask-Session 0.8.0 (2024-03-26) is the latest release. msgspec 0.21.1 is used rather than 0.22.0 (2026-09-29), which is under 14 days old and fixes no security issue. Werkzeug 3.1.9 (2026-09-27) is taken at 13 days old because it fixes GHSA-g6x2-hccm-hh4m (CVE-2026-102598). OSV listed no advisories for the pinned versions. Rerun on CPython 3.14.8, the 30 September 2026 expedited security release.

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. OWASP Session Management Cheat Sheet (session ID renewal, cookie attributes, timeouts)
  2. Flask-Session: Security (session fixation and regenerate)
  3. Flask-Session 0.8.0 source: base.py (regenerate, open_session, save_session)
  4. Flask: configuration handling (SESSION_COOKIE_* and PERMANENT_SESSION_LIFETIME)
  5. Flask: Security considerations (Set-Cookie options)
  6. Flask tutorial: Blueprints and Views (the login view's session.clear())
  7. MDN: Set-Cookie (Secure, HttpOnly, SameSite and the __Host- prefix)

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.