Sessions, Cookies & Flashing

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Flask 3.1.3 (released 2026-02-18), Werkzeug 3.1.9, ItsDangerous 2.2.0 and Python 3.13, checked on 2026-10-07; the examples were executed with those versions (and Flask-Session 0.8.0 for the server-side section). Flask 3.2 is not released yet, and its unreleased changelog contains nothing that changes the session API. HTTP is stateless: every request arrives without memory of the previous one. A web application that wants to keep a visitor’s language, a half-filled cart, a "recently viewed books" list or the fact that somebody is logged in has to store that state somewhere and recognise the visitor again. Flask offers three tools for it, and the page covers all three with the running Bookshelf application: the session (a per-visitor dictionary), plain cookies (small values the browser stores and returns), and message flashing (a one-shot message for the next request, built on top of the session). The default session stores its data in the browser inside a signed cookie, which has consequences for secrecy, size and revocation that the following sections explain, together with the alternatives: a custom SessionInterface and server-side sessions with Flask-Session and Redis.

Where Per-Visitor State Can Live

There are only a few places to keep state between requests, and Flask supports each of them. The choice is mostly a trade-off between simplicity, size, secrecy and the ability to revoke.

Place What the browser holds Strengths Weaknesses

Signed cookie session (Flask default)

The whole session payload, signed

No server storage; scales horizontally with no shared store; nothing to clean up

Readable by the user (not encrypted); about 4 KB limit; cannot be revoked before it expires; every request carries it

Server-side session (Flask-Session, own interface)

Only a random session identifier

Any size; secret to the user; can be revoked by deleting the record

Needs a shared store (Redis, database); the store is a new dependency and a new thing to secure

Plain cookie (set_cookie)

One named value

Fine for harmless preferences such as a theme

The user can edit it freely unless you sign it; same size limit

Database row linked to the user

A login identifier (often via the session)

Long-lived, queryable, survives a changed key

It is data, not session; use the database pages for it

Token in a header (JWT, API keys)

Held by client code, not by the browser’s cookie machinery

Natural for APIs and non-browser clients

The client must store it safely; see the page about JWT and API authentication

The session object is a proxy to the session of the current request (the same pattern as request, see Application & Request Lifecycle). It is opened when the request context is pushed, before the URL is matched, and saved after the response has been created, so before_request hooks, views and after_request hooks all see the same object. The figure shows what happens for the default implementation, which lives in flask.sessions.SecureCookieSessionInterface and delegates the signing to ItsDangerous.

sequenceDiagram participant B as Browser participant F as Flask participant I as ItsDangerous B->>F: GET /books/1 (no cookie yet) F->>F: open_session gives an empty session F->>F: view writes session recent = [1] F->>I: dumps(session) with SECRET_KEY and salt I-->>F: payload.timestamp.signature F-->>B: 200 and Set-Cookie session=payload.timestamp.signature B->>F: GET /books/2 with Cookie session=... F->>I: loads(cookie, max_age) with current and fallback keys I-->>F: the dictionary, or BadSignature F->>F: view appends 2 to recent F-->>B: 200 and a new Set-Cookie because the session changed
Figure 1. The round trip of a signed cookie session

Every arrow into ItsDangerous is a check, not a lookup: Flask never stores anything about the visitor. That is why a copy of a valid cookie stays valid until its signature expires, a point the section about revocation returns to.

Using the Session

flask.session behaves like a dictionary. Reading works with [], get(), in; writing with item assignment, setdefault(), pop() and clear(). The only prerequisite is a SECRET_KEY (see SECRET_KEY: Generating, Loading and Rotating), because the default session signs its cookie with it. The Bookshelf below remembers which books a visitor opened last:

import secrets

from flask import Flask, jsonify, session

app = Flask(__name__)
# Demo only: a key that exists for the lifetime of this process. Real applications
# read a stable key from the environment or from instance configuration.
app.config["SECRET_KEY"] = secrets.token_hex()


@app.get("/books/<int:book_id>")
def show_book(book_id: int):
    recent = session.get("recent", [])
    if book_id in recent:
        recent.remove(book_id)
    recent.insert(0, book_id)
    session["recent"] = recent[:5]  # assigning marks the session as modified
    return jsonify(book=book_id)


@app.get("/recent")
def recent_books():
    return jsonify(recent=session.get("recent", []))


client = app.test_client()
client.get("/books/10")
client.get("/books/20")
client.get("/books/10")
assert client.get("/recent").json == {"recent": [10, 20]}
print(client.get_cookie("session").value[:40], "...")

The test client keeps cookies between calls, exactly like a browser, which is why the second request already sees the data of the first. Two behaviours deserve attention.

  • No secret key, no session. If SECRET_KEY is not set, open_session() returns None and Flask uses a NullSession: reading works (it is empty), but writing raises a RuntimeError that explains the missing key.

  • Only changes Flask can see are saved. The session class notices item assignment and the dictionary methods, but not a mutation inside a stored list or dictionary. The example above reassigns session["recent"] for that reason.

from flask import Flask, session

demo = Flask(__name__)

# 1. Without a key the session is read-only and empty.
with demo.test_request_context():
    assert dict(session) == {}
    try:
        session["x"] = 1
    except RuntimeError as exc:
        print(exc)

# 2. With a key, in-place mutation of a nested object is not detected.
demo.config["SECRET_KEY"] = secrets.token_hex()
with demo.test_request_context():
    session["cart"] = [1]
    session.modified = False          # pretend the cookie was just saved
    session["cart"].append(2)
    assert session.modified is False  # the change would be lost
    session.modified = True           # tell Flask explicitly ...
    session["cart"] = session["cart"] + [3]  # ... or, better, assign a new object

Prefer reassigning a new list or dictionary; set session.modified = True only when you must mutate in place. The session also has session.new (not guaranteed by every implementation), session.accessed (set whenever the session is read through the request context, used for Vary: Cookie, see Vary: Cookie and Caching) and session.permanent (see Session Lifetime: Permanent and Browser Sessions).

What Can Be Stored

The payload is serialised as JSON by TaggedJSONSerializer (the default since Flask 0.10, replacing pickle to limit what an attacker gets if the key leaks). On top of the JSON types it adds tags for tuple, bytes, uuid.UUID, datetime.datetime and markupsafe.Markup. Everything else, such as a set, a Decimal, a SQLAlchemy model instance or a date, fails when the session is saved (TypeError), after the view has run. Store identifiers and small primitive values and load the object from the database on the next request.

import uuid
from datetime import datetime, timezone

from flask.json.tag import TaggedJSONSerializer

tagged = TaggedJSONSerializer()
original = {
    "when": datetime(2026, 10, 7, 9, 30, tzinfo=timezone.utc),
    "ids": (1, 2, 3),
    "token": uuid.uuid4(),
}
restored = tagged.loads(tagged.dumps(original))
assert restored == original and isinstance(restored["ids"], tuple)

try:
    tagged.dumps({"tags": {"novel", "classic"}})
except TypeError as exc:
    print("sets are not supported:", exc)

How the Signed Cookie Works, and Why Signed Is Not Encrypted

The default interface calls URLSafeTimedSerializer from ItsDangerous with salt="cookie-session", an HMAC key derived from SECRET_KEY, and SHA-1 as the digest (HMAC-SHA1 is still sound as a MAC; the digest can be replaced, see Replacing the Session Machinery: SessionInterface). The cookie value has three dot-separated parts: the URL-safe base64 payload (prefixed with a dot when it was zlib-compressed because that was shorter), a timestamp, and the signature. The signature covers the payload and the timestamp, so a tampered cookie fails verification, and loads() rejects a cookie older than PERMANENT_SESSION_LIFETIME (see Session Lifetime: Permanent and Browser Sessions).

The payload is not encrypted. Anybody who has the cookie, including the visitor and any script or browser extension that can read it, can decode the contents without any key:

import base64
import json
import zlib

from flask import Flask, session

app = Flask(__name__)
app.config["SECRET_KEY"] = secrets.token_hex()


@app.post("/login-demo")
def login_demo():
    session["user_id"] = 42
    session["role"] = "reader"
    return "ok"


client = app.test_client()
client.post("/login-demo")
cookie = client.get_cookie("session").value

payload, timestamp, signature = cookie.rsplit(".", 2)
compressed = payload.startswith(".")
raw = base64.urlsafe_b64decode(payload.lstrip(".") + "=" * (-len(payload) % 4))
if compressed:
    raw = zlib.decompress(raw)
print(json.loads(raw))  # {'role': 'reader', 'user_id': 42}: readable without the key

# Verifying and decoding the intended way needs the key:
serializer = app.session_interface.get_signing_serializer(app)
assert serializer.loads(cookie, max_age=60) == {"user_id": 42, "role": "reader"}

Three practical rules follow.

  • Never put a secret in the session: no password, no API token of a third party, no personal data you would not show to the visitor themselves. The visitor can read it, and so can anyone who copies the cookie.

  • Do not trust the content more than the signature allows. A valid signature proves the data was written by an application holding the key; it does not prove the data is still true (a user may have been disabled since). Re-check identity-sensitive facts against the database.

  • Whoever has the SECRET_KEY can forge any session, including one that claims to be an administrator. Treat the key like a database password: keep it out of source control, logs and error pages. Flask-style session forging tools exist precisely because weak or leaked keys are common.

The books get this wrong in places. Full-Stack Flask and React describes the Flask session cookie as encrypted; it is signed only, as the example shows. If the cookie must be secret, use server-side sessions (see Server-Side Sessions with Flask-Session) or encrypt the data yourself with a vetted library before storing it.

SECRET_KEY: Generating, Loading and Rotating

The key must be long, random and different per deployment. The official recommendation is to generate it with the secrets module and copy it into configuration:

python -c 'import secrets; print(secrets.token_hex())'

Load it from the environment or from a file that is not in version control. Flask can read variables prefixed with FLASK_ into app.config (from_prefixed_env()), or you can read it explicitly. Failing at start-up when the key is missing is better than discovering the problem when the first visitor logs in:

import os

from flask import Flask

# In production the shell or the process manager sets this; here the demo does it.
os.environ["FLASK_SECRET_KEY"] = secrets.token_hex()


def create_app() -> Flask:
    app = Flask(__name__, instance_relative_config=True)
    app.config.from_prefixed_env()                       # FLASK_SECRET_KEY -> SECRET_KEY
    app.config.from_pyfile("config.py", silent=True)     # optional, lives in instance/
    if not app.config.get("SECRET_KEY"):
        raise RuntimeError("SECRET_KEY is not configured")
    return app


assert create_app().secret_key

The configuration page, Configuration, explains from_prefixed_env(), instance folders and class-based configuration in detail. The books assign the placeholder string secret_key to the key or a literal bytes string in the source (it appears in the official quickstart too, with a warning to change it). Do not copy it: a key committed to a repository must be treated as leaked.

Rotating the Key with SECRET_KEY_FALLBACKS

Changing SECRET_KEY invalidates every outstanding session, so every visitor is logged out. Flask 3.1 added SECRET_KEY_FALLBACKS, a list of old keys that are still accepted for verification while new cookies are signed with the current SECRET_KEY. A rotation then goes in three steps: put the old key in the list and install the new one, wait for the old cookies to expire or be refreshed, then remove the old key from the list (each extra key adds work to every request). Flask 3.1.1 fixed the order in which fallback keys are tried (GHSA-4grg-w6v8-c28g), so use Flask 3.1.1 or newer.

import secrets

from flask import Flask, session

old_key = secrets.token_hex()
new_key = secrets.token_hex()


def make_app(secret_key, fallbacks=None) -> Flask:
    app = Flask(__name__)
    app.config["SECRET_KEY"] = secret_key
    if fallbacks:
        app.config["SECRET_KEY_FALLBACKS"] = fallbacks

    @app.post("/login-demo")
    def login():
        session["user_id"] = 7
        return "ok"

    @app.get("/who")
    def who():
        return {"user_id": session.get("user_id")}

    return app


before = make_app(old_key).test_client()
before.post("/login-demo")
cookie = before.get_cookie("session").value

# After deploying the new key without a fallback: everybody is signed out.
no_fallback = make_app(new_key).test_client()
no_fallback.set_cookie("session", cookie)
assert no_fallback.get("/who").json == {"user_id": None}

# With the old key in SECRET_KEY_FALLBACKS the old cookie is still accepted.
with_fallback = make_app(new_key, [old_key]).test_client()
with_fallback.set_cookie("session", cookie)
assert with_fallback.get("/who").json == {"user_id": 7}

Two details of rotation. First, a cookie is only re-signed with the new key when the session is saved again, which happens when it changes or, for a permanent session, on every response while SESSION_REFRESH_EACH_REQUEST is true. A non-permanent, unchanged session keeps its old signature until the visitor changes it. Second, the official docs point out that extensions using SECRET_KEY may not support the fallbacks yet. Flask-WTF’s CSRF tokens and the other signed values you create with the key are signed by their own code, so check each of them separately.

Session Lifetime: Permanent and Browser Sessions

By default a session cookie has no Expires attribute, so the browser discards it when it is closed (modern browsers that restore tabs may keep it longer). Setting session.permanent = True gives the cookie an expiration time PERMANENT_SESSION_LIFETIME in the future (31 days by default; a timedelta or a number of seconds). The flag is stored in the session itself, under the key _permanent, so you set it once, usually at login.

Two points are easy to miss. The expiry is enforced on the server too: open_session() passes the lifetime as max_age to ItsDangerous, so a cookie whose signature is older than the lifetime is rejected even if the browser still sends it. This holds for non-permanent sessions as well, which is a defence against replaying an old cookie. And SESSION_REFRESH_EACH_REQUEST (default true) makes Flask send the cookie with every response of a permanent session, so the lifetime is a sliding window of inactivity. Set it to false to get a fixed lifetime from the last real change, at the price of a session that expires even for an active user.

import time
from datetime import timedelta

from flask import Flask, session

app = Flask(__name__)
app.config["SECRET_KEY"] = secrets.token_hex()
app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(seconds=1)


@app.post("/remember")
def remember():
    session.permanent = True
    session["user_id"] = 5
    return "ok"


@app.get("/who")
def who():
    return {"user_id": session.get("user_id")}


client = app.test_client()
response = client.post("/remember")
set_cookie = response.headers["Set-Cookie"]
assert "Expires=" in set_cookie and "HttpOnly" in set_cookie
assert client.get("/who").json == {"user_id": 5}

time.sleep(2)  # the signature is now older than PERMANENT_SESSION_LIFETIME
assert client.get("/who").json == {"user_id": None}

For an idle timeout on a banking-style application, combine a short lifetime with the refresh behaviour. For an absolute maximum login age, store the login time in the session and check it in a before_request hook, because the sliding refresh would otherwise keep a stolen cookie alive for as long as it is used.

The cookie attributes are configuration keys, read through the get_cookie_* methods of the session interface. They only affect the session cookie; the same attributes exist as arguments of set_cookie() for your own cookies.

Key Default Effect

SESSION_COOKIE_NAME

"session"

The cookie name. Change it when two applications share a host (see below) or to use a __Host- prefix

SESSION_COOKIE_DOMAIN

None

Domain attribute. Unset means the cookie is returned only to the exact host that set it; setting it shares the cookie with every subdomain. Not set by default and no longer derived from SERVER_NAME since Flask 2.3

SESSION_COOKIE_PATH

None

Path attribute. Unset means APPLICATION_ROOT, or / if that is not set either

SESSION_COOKIE_HTTPONLY

True

Hides the cookie from JavaScript (document.cookie), which limits what XSS can steal. Keep it on

SESSION_COOKIE_SECURE

False

The browser sends the cookie only over HTTPS. Turn it on in production behind HTTPS

SESSION_COOKIE_PARTITIONED

False

Adds the Partitioned attribute (CHIPS) so that a cookie set in a third-party iframe is keyed to the top-level site. Added in Flask 3.1. It needs Secure, and Werkzeug adds Secure when partitioned is true

SESSION_COOKIE_SAMESITE

None

SameSite attribute: "Lax" (recommended) or "Strict". Lax stops the cookie being sent on cross-site POSTs, iframes and subrequests but allows it on top-level link navigations; Strict withholds it from every cross-site request, including a click on a link from another site

PERMANENT_SESSION_LIFETIME

31 days

Lifetime of a permanent session and the maximum signature age

SESSION_REFRESH_EACH_REQUEST

True

Re-send the cookie on every response of a permanent session

MAX_COOKIE_SIZE

4093

Werkzeug warns when a cookie header is larger than this; browsers may drop larger cookies silently. 0 disables the warning

A production configuration for the Bookshelf looks like this. The __Host- prefix is a browser rule: such a cookie is accepted only if it is Secure, has Path=/ and has no Domain, which makes it impossible for a sibling subdomain to overwrite it.

import secrets
from datetime import timedelta

from flask import Flask, session

app = Flask(__name__)
app.config.update(
    SECRET_KEY=secrets.token_hex(),
    SESSION_COOKIE_NAME="__Host-bookshelf-session",
    SESSION_COOKIE_SECURE=True,
    SESSION_COOKIE_HTTPONLY=True,
    SESSION_COOKIE_SAMESITE="Lax",
    PERMANENT_SESSION_LIFETIME=timedelta(hours=8),
)


@app.get("/start")
def start():
    session.permanent = True
    session["started"] = True
    return "ok"


header = app.test_client().get("/start", base_url="https://localhost").headers["Set-Cookie"]
print(header)
for attribute in ("__Host-bookshelf-session=", "Secure", "HttpOnly", "SameSite=Lax", "Path=/", "Expires="):
    assert attribute in header, attribute
assert "Domain" not in header

# Partitioned cookies (CHIPS) for an application embedded in third-party iframes.
app.config["SESSION_COOKIE_PARTITIONED"] = True
partitioned = app.test_client().get("/start", base_url="https://localhost").headers["Set-Cookie"]
assert "Partitioned" in partitioned and "Secure" in partitioned

Pitfalls of the cookie options:

  • Cookies ignore the port. Two Flask applications on localhost:5000 and localhost:5001 share the same cookie jar, and each overwrites the other’s session cookie (the result is a visitor who is mysteriously logged out). Give each application its own SESSION_COOKIE_NAME during development.

  • Changing the domain or path after the fact leaves the browser with two cookies of the same name. The official docs advise changing SESSION_COOKIE_NAME at the same time in that case, so the old cookies are simply ignored.

  • SESSION_COOKIE_SECURE=True over plain HTTP makes the browser drop the cookie, so nothing seems to be stored. Use HTTPS (or a reverse proxy that terminates it, see the production pages) in every environment where it is on.

  • SameSite=Strict breaks the "arrive logged in from an e-mail link" case, because the first request from another site carries no cookie. Lax is the usual compromise; neither replaces CSRF tokens (see the page about Flask-WTF and the security considerations page).

  • A cookie shared across subdomains (SESSION_COOKIE_DOMAIN=".example.com") is readable by every subdomain, so one vulnerable or untrusted subdomain compromises the rest.

Vary: Cookie and Caching

A response that depends on the session must not be served from a shared cache to a different visitor. Since Flask 2.3.2 the session interface adds Vary: Cookie to every response in which the session was accessed, modified or refreshed. Flask 3.1.3 widened "accessed": operations that only look at keys, such as in and len(), count too (GHSA-68rp-wp8r-4726), because they also reveal information that depends on the session.

import secrets

from flask import Flask, session

app = Flask(__name__)
app.config["SECRET_KEY"] = secrets.token_hex()


@app.get("/public")
def public():
    return "same for everybody"


@app.get("/greeting")
def greeting():
    return "hello again" if "name" in session else "hello"


client = app.test_client()
assert "Vary" not in client.get("/public").headers
assert client.get("/greeting").headers["Vary"] == "Cookie"

The consequence for performance is that any page that touches the session (typically through the base template that shows the login state or flashed messages) becomes uncacheable by shared caches. Keep cacheable pages, such as static files and public catalogue pages, free of session access, or cache them on the server side keyed by what matters.

Size, Performance and Revocation

Size. Browsers store roughly 4 KB per cookie, and the whole cookie header travels with every request to the domain, including requests for static files. Werkzeug emits a UserWarning when a cookie header exceeds MAX_COOKIE_SIZE (4093 bytes), but a browser that refuses the cookie does so silently: the visible symptom is data that "disappears". Keep the session to identifiers and flags, and move anything bulky to the database or a server-side session.

import secrets
import warnings

from flask import Flask, session

app = Flask(__name__)
app.config["SECRET_KEY"] = secrets.token_hex()


@app.get("/fill")
def fill():
    session["blob"] = secrets.token_hex(3000)  # random, so it does not compress
    return "ok"


with warnings.catch_warnings(record=True) as caught:
    warnings.simplefilter("always")
    app.test_client().get("/fill")
assert any("too large" in str(w.message) for w in caught)
print(str(caught[0].message)[:100])

Revocation. Because nothing is stored on the server, "logout" can only clear the cookie in the visitor’s browser. A copy of the cookie made before logout is still valid until its signature expires. The example makes this concrete:

import secrets

from flask import Flask, abort, request, session
from werkzeug.security import check_password_hash, generate_password_hash

app = Flask(__name__)
app.config["SECRET_KEY"] = secrets.token_hex()

demo_password = secrets.token_urlsafe(12)
USERS = {"alice": generate_password_hash(demo_password)}


@app.post("/login/<name>")
def login(name: str):
    if name not in USERS or not check_password_hash(USERS[name], request.form["password"]):
        abort(401)
    session.clear()            # never keep data from before the login (session fixation)
    session["user"] = name
    return "ok"


@app.post("/logout")
def logout():
    session.clear()
    return "bye"


@app.get("/me")
def me():
    return {"user": session.get("user")}


client = app.test_client()
client.post("/login/alice", data={"password": demo_password})
stolen = client.get_cookie("session").value        # an attacker copied the cookie
client.post("/logout")
assert client.get("/me").json == {"user": None}    # the browser forgot the session ...

attacker = app.test_client()
attacker.set_cookie("session", stolen)
assert attacker.get("/me").json == {"user": "alice"}  # ... but the copy still works

When this matters (an administrator account, a compromised device, a "log out of all devices" button), use one of: a short lifetime plus re-authentication for sensitive actions; a per-user "session version" number stored in the database and in the session, and compared in a before_request hook (bump it to invalidate every cookie of that user); or a server-side session that you can delete (see Server-Side Sessions with Flask-Session). Rotating SECRET_KEY without a fallback revokes everything at once, which is the emergency button.

Calling session.clear() at login is also the defence against session fixation, where an attacker plants a known session before the victim logs in. With signed cookie sessions the attacker cannot choose the identifier, because the identifier is the signed content, so the risk is smaller than with server-side session ids; with a server-side store regenerate the id at login (Flask-Session provides regenerate()).

Replacing the Session Machinery: SessionInterface

Flask delegates everything about sessions to app.session_interface. To change how sessions are loaded and stored, subclass flask.sessions.SessionInterface and implement open_session(app, request) and save_session(app, session, response). The helper methods get_cookie_name, get_cookie_domain, get_cookie_path, get_cookie_httponly, get_cookie_secure, get_cookie_samesite, get_cookie_partitioned, get_expiration_time and should_set_cookie already implement the configuration keys, so a custom interface honours the same settings as the default one. The returned session object must act like a dictionary and provide the SessionMixin attributes (permanent, new, modified, accessed). Flask calls open_session early in request handling and save_session after the response is built, unless is_null_session() is true.

The smallest useful change is to subclass SecureCookieSessionInterface and override its class attributes: salt, digest_method, key_derivation, serializer and session_class. This switches the digest to SHA-256 (existing cookies stop verifying, so plan it like a key rotation):

import hashlib
import secrets

from flask import Flask, session
from flask.sessions import SecureCookieSessionInterface


class Sha256SessionInterface(SecureCookieSessionInterface):
    digest_method = staticmethod(hashlib.sha256)


app = Flask(__name__)
app.config["SECRET_KEY"] = secrets.token_hex()
app.session_interface = Sha256SessionInterface()


@app.get("/set")
def set_value():
    session["theme"] = "dark"
    return "ok"


@app.get("/get")
def get_value():
    return {"theme": session.get("theme")}


client = app.test_client()
client.get("/set")
assert client.get("/get").json == {"theme": "dark"}

A full replacement keeps the data on the server and puts only an identifier in the cookie. The example below is a deliberately small in-memory version, to show the contract; it forgets everything when the process restarts and is not shared between worker processes, so it is not for production. Real stores add expiry (a TTL), a lock-free design for concurrent requests and cleanup, which is what Flask-Session provides.

import secrets

from flask import Flask, session
from flask.sessions import SessionInterface, SessionMixin
from werkzeug.datastructures import CallbackDict


class ServerSession(CallbackDict, SessionMixin):
    def __init__(self, initial=None, sid=None, new=False):
        def on_update(self):
            self.modified = True

        super().__init__(initial, on_update)
        self.sid = sid
        self.new = new
        self.modified = False


class DictSessionInterface(SessionInterface):
    def __init__(self):
        self.store: dict[str, dict] = {}

    def open_session(self, app, request):
        sid = request.cookies.get(self.get_cookie_name(app))
        if sid and sid in self.store:  # an id we did not issue is never trusted
            return ServerSession(self.store[sid], sid=sid)
        return ServerSession(sid=secrets.token_urlsafe(32), new=True)

    def save_session(self, app, session, response):
        name = self.get_cookie_name(app)
        domain = self.get_cookie_domain(app)
        path = self.get_cookie_path(app)
        if session.accessed:
            response.vary.add("Cookie")
        if not session:
            if session.modified:
                self.store.pop(session.sid, None)
                response.delete_cookie(name, domain=domain, path=path)
            return
        if not self.should_set_cookie(app, session):
            return
        self.store[session.sid] = dict(session)
        response.set_cookie(
            name,
            session.sid,
            expires=self.get_expiration_time(app, session),
            httponly=self.get_cookie_httponly(app),
            secure=self.get_cookie_secure(app),
            samesite=self.get_cookie_samesite(app),
            domain=domain,
            path=path,
        )


app = Flask(__name__)
interface = DictSessionInterface()
app.session_interface = interface


@app.post("/cart/<int:book_id>")
def add_to_cart(book_id: int):
    session["cart"] = session.get("cart", []) + [book_id]
    return {"cart": session["cart"]}


@app.post("/cart/clear")
def clear_cart():
    session.clear()
    return "cleared"


client = app.test_client()
client.post("/cart/3")
assert client.post("/cart/4").json == {"cart": [3, 4]}
sid = client.get_cookie("session").value
assert interface.store[sid] == {"cart": [3, 4]}   # the data is on the server ...
assert len(sid) == 43                              # ... the cookie is only an opaque identifier
client.post("/cart/clear")
assert sid not in interface.store

Because a session may be opened and saved concurrently by several requests of the same visitor, a real interface has to decide what happens to simultaneous writes (usually last write wins). Also remember that a view returning a streaming generator saves the session when the response object is created, so writes made inside the generator later are not stored.

Server-Side Sessions with Flask-Session

Flask-Session (Pallets-Eco, 0.8.0, released 2024-03-26 at the time of writing, Python 3.8+) is the usual drop-in replacement. It installs a server-side SessionInterface selected with SESSION_TYPE, keeps the data in a store, and puts a random identifier (secrets.token_urlsafe) in the session cookie. Because that cookie holds no data, the visitor cannot read the session, the size limit goes away and a record can be deleted to revoke a session. The flask.session API in your views is unchanged. Redis is the standard choice because it expires keys on its own.

Key Default Effect

SESSION_TYPE

"null"

Backend: "redis", "memcached", "filesystem", "cachelib", "mongodb", "sqlalchemy" or "dynamodb". It must be set, or the interface is not created

SESSION_REDIS

none

A redis.Redis client (similar keys exist for the other backends: SESSION_MEMCACHED, SESSION_CACHELIB, SESSION_MONGODB, SESSION_SQLALCHEMY, SESSION_DYNAMODB)

SESSION_PERMANENT

True

Whether new sessions are permanent. Their stored lifetime is PERMANENT_SESSION_LIFETIME, applied as the key’s TTL

SESSION_KEY_PREFIX

"session:"

Prefix of the store keys, so several applications can share one Redis

SESSION_ID_LENGTH

32

Number of random bytes behind the identifier

SESSION_SERIALIZATION_FORMAT

"msgpack"

How the data is serialised in the store: "msgpack" or "json". Data must fit those formats (no sets, no model instances)

SESSION_USE_SIGNER

False

Signs the cookie value. Deprecated in 0.8 and slated for removal; a long random identifier does not need it

SESSION_CLEANUP_N_REQUESTS

none

For backends without TTL (SQL, MongoDB): purge expired rows every N requests, otherwise use the cleanup command the extension registers

The cookie itself is still configured through the SESSION_COOKIE_* keys listed earlier (the installed 0.8.0 does not pass partitioned to the cookie, so check that attribute in the response if you rely on CHIPS). The session data no longer depends on SECRET_KEY, but keep the key configured: CSRF tokens and other signed values still need it.

pip install Flask-Session redis

The example uses fakeredis instead of a server so that it can run anywhere; the only change for production is how the client is created. It also shows the two security features: logging out deletes the record, so a copied cookie becomes worthless, and regenerate() issues a new identifier at login to prevent fixation.

from datetime import timedelta

import fakeredis  # production: redis.Redis.from_url(os.environ["REDIS_URL"])
from flask import Flask, session
from flask_session import Session

redis_client = fakeredis.FakeStrictRedis()

app = Flask(__name__)
app.config.update(
    SESSION_TYPE="redis",
    SESSION_REDIS=redis_client,
    SESSION_KEY_PREFIX="bookshelf:session:",
    PERMANENT_SESSION_LIFETIME=timedelta(hours=8),
    SESSION_COOKIE_HTTPONLY=True,
    SESSION_COOKIE_SAMESITE="Lax",
)
Session(app)


@app.post("/login/<name>")
def login(name: str):
    session["user"] = name                          # (check the password first in real code)
    app.session_interface.regenerate(session)       # fresh identifier: no fixation
    return "ok"


@app.post("/logout")
def logout():
    session.clear()                                 # deletes the record in Redis
    return "bye"


@app.get("/me")
def me():
    return {"user": session.get("user")}


client = app.test_client()
client.post("/login/alice")
sid = client.get_cookie("session").value
key = f"bookshelf:session:{sid}"
assert redis_client.exists(key)
assert 0 < redis_client.ttl(key) <= 8 * 3600       # expiry is enforced by Redis
assert client.get("/me").json == {"user": "alice"}

copy = app.test_client()
copy.set_cookie("session", sid)
client.post("/logout")
assert not redis_client.exists(key)                 # revoked on the server
assert copy.get("/me").json == {"user": None}       # the copied cookie is useless now

Operational notes. Use a Redis that is not reachable from the internet and that requires authentication, because whoever can write to the store can create sessions. Version 0.8.0 keeps a fallback that still reads old pickled records (a leftover of earlier releases), another reason to restrict writes to the store. Size the Redis memory for (active visitors x average session) and set a TTL (the default permanent lifetime does). Flask-Session is a small extension and its last release at the time of writing is from 2024; check its status when you start a project. The Redis engine itself is covered by the Redis pages, the extension ecosystem and its maintenance status by the page about extensions and the ecosystem, and Flask-Login and Flask-Security (later pages in this section) use whatever session interface is configured.

Reading and Setting Raw Cookies

The session is one cookie. For anything else you read cookies from request.cookies (an immutable dictionary of the cookies the browser sent, see Request Data) and write them on a response with set_cookie(). The arguments, from Werkzeug’s Response.set_cookie, are key, value, max_age, expires, path, domain, secure, httponly, samesite and partitioned. Remove a cookie with delete_cookie(), which needs the same path and domain (and ideally the other attributes) that were used to set it, because the browser identifies a cookie by name, domain and path.

  • max_age (seconds or a timedelta) and expires (a date) set the lifetime; with neither, the cookie lasts until the browser closes. max_age wins in modern browsers.

  • secure, httponly and samesite mean the same as for the session cookie. Use httponly=False only for a value that JavaScript really needs to read, such as a theme the front end applies.

  • A cookie is plain text chosen by the client. Treat request.cookies like any other user input, and never store anything the user should not be able to change, unless it is signed.

The example stores a harmless display preference as a plain cookie and a second preference as a signed cookie, using ItsDangerous directly with its own salt (a salt separates uses of the same key, so a value signed for one purpose cannot be replayed for another):

import secrets

from flask import Flask, jsonify, make_response, request
from itsdangerous import BadSignature, URLSafeTimedSerializer

app = Flask(__name__)
app.config["SECRET_KEY"] = secrets.token_hex()
signer = URLSafeTimedSerializer(app.config["SECRET_KEY"], salt="bookshelf-page-size")


@app.post("/preferences/theme/<theme>")
def set_theme(theme: str):
    if theme not in {"light", "dark"}:
        return {"error": "unknown theme"}, 400
    response = make_response({"theme": theme})
    response.set_cookie("theme", theme, max_age=60 * 60 * 24 * 365, samesite="Lax", httponly=False)
    return response


@app.post("/preferences/page-size/<int:size>")
def set_page_size(size: int):
    response = make_response({"page_size": size})
    response.set_cookie("page_size", signer.dumps(size), max_age=3600, samesite="Lax", httponly=True)
    return response


@app.get("/preferences")
def preferences():
    theme = request.cookies.get("theme", "light")
    try:
        page_size = signer.loads(request.cookies.get("page_size", ""), max_age=3600)
    except BadSignature:
        page_size = 20          # missing or tampered: fall back to the default
    return jsonify(theme=theme, page_size=page_size)


@app.post("/preferences/reset")
def reset():
    response = make_response("reset")
    response.delete_cookie("theme", samesite="Lax")
    response.delete_cookie("page_size", samesite="Lax")
    return response


client = app.test_client()
client.post("/preferences/theme/dark")
client.post("/preferences/page-size/50")
assert client.get("/preferences").json == {"theme": "dark", "page_size": 50}

client.set_cookie("page_size", "50")             # tampering: an unsigned value
assert client.get("/preferences").json["page_size"] == 20

client.post("/preferences/reset")
assert client.get_cookie("theme") is None
assert client.get("/preferences").json == {"theme": "light", "page_size": 20}

Cookies set on a response created in a view only reach the browser if that response is the one returned. make_response() creates the response object from whatever the view would return (see Responses & JSON). To add a cookie to every response (for example a language cookie), set it in an after_request hook. In tests, the client has client.set_cookie(key, value), client.get_cookie(key) and client.delete_cookie(key) since Werkzeug 2.3; the older signature that took the domain as a first positional argument is gone.

Message Flashing

Flashing implements the "message after redirect" pattern. A view records a message with flash(), redirects, and the next request displays it once and removes it. It is feedback for the visitor ("Book saved", "Wrong password") that must survive the redirect of the Post/Redirect/Get pattern, where a plain variable would be lost.

Flask stores the messages in the session under the key _flashes, as a list of (category, message) tuples. That means flashing needs a working session and a SECRET_KEY, and that with the default interface the messages travel in the signed cookie. get_flashed_messages() reads them and removes them from the session the first time it is called in a request; further calls in the same request return the same list.

sequenceDiagram participant B as Browser participant F as Flask B->>F: POST /books (new book) F->>F: flash("Book saved", "success") stores it in session _flashes F-->>B: 302 or 303 to /books and Set-Cookie session B->>F: GET /books with the cookie F->>F: template calls get_flashed_messages() which pops _flashes F-->>B: 200 with the message in the page and an updated cookie B->>F: GET /books again F-->>B: 200 without the message
Figure 2. Flashing across a redirect
import secrets

from flask import (
    Flask,
    flash,
    get_flashed_messages,
    redirect,
    render_template_string,
    request,
    url_for,
)

app = Flask(__name__)
app.config["SECRET_KEY"] = secrets.token_hex()

LAYOUT = """
{% with messages = get_flashed_messages(with_categories=true) %}
  {% if messages %}
    <ul class="flashes">
      {% for category, message in messages %}
        <li class="{{ category }}">{{ message }}</li>
      {% endfor %}
    </ul>
  {% endif %}
{% endwith %}
<h1>Books</h1>
"""


@app.get("/books")
def book_list():
    return render_template_string(LAYOUT)


@app.post("/books")
def create_book():
    title = request.form.get("title", "").strip()
    if not title:
        flash("A title is required.", "error")
        return redirect(url_for("book_list"))
    flash(f"Saved {title}", "success")
    flash("Remember to add the author.", "warning")
    return redirect(url_for("book_list"))


client = app.test_client()
page = client.post("/books", data={"title": "<b>Dune</b>"}, follow_redirects=True).text
assert 'class="success">Saved &lt;b&gt;Dune&lt;/b&gt;' in page     # autoescaped
assert 'class="warning"' in page

assert "flashes" not in client.get("/books").text                      # shown once only

flash(message, category="message") accepts any string as the category. The documentation recommends "message", "error", "info" and "warning"; the example used "success", which is as valid. get_flashed_messages() has two independent parameters:

  • with_categories=True returns (category, message) tuples instead of bare strings. The default is False, which is why older templates that unpack two values fail with it left out.

  • category_filter=("error",) keeps only the listed categories. Because the call pops everything at its first use, a second call with a different filter in the same request still sees the full list (the first call cached it), which is what lets a layout render errors in one block and the rest in another.

import secrets

from flask import Flask, flash, get_flashed_messages, redirect, render_template_string

app = Flask(__name__)
app.config["SECRET_KEY"] = secrets.token_hex()


@app.post("/demo")
def demo():
    flash("Disk almost full", "warning")
    flash("Import failed", "error")
    flash("Welcome back")           # the default category is "message"
    return redirect("/show")


@app.get("/show")
def show():
    errors = get_flashed_messages(category_filter=("error",))
    everything = get_flashed_messages(with_categories=True)
    return render_template_string("{{ errors }}|{{ everything }}", errors=errors, everything=everything)


client = app.test_client()
text = client.post("/demo", follow_redirects=True).text
assert "Import failed" in text and "Disk almost full" in text
assert "(&#39;message&#39;, &#39;Welcome back&#39;)" in text

Each message is also announced on the message_flashed signal (from Blinker), with the message and category as arguments, which is how tests and logging code can observe flashes without parsing HTML:

from flask import message_flashed

recorded = []


def record(sender, message, category, **extra):
    recorded.append((category, message))


with message_flashed.connected_to(record, app):
    client.post("/demo")

assert recorded == [("warning", "Disk almost full"), ("error", "Import failed"), ("message", "Welcome back")]

Pitfalls of Flashing

  • Unread flashes accumulate in the cookie. Messages are removed only when something calls get_flashed_messages(). If a request flashes a message and the redirect target never renders the layout (a JSON endpoint, a download), the messages stay in the session and grow the cookie. The official page warns that messages too large for the cookie make flashing fail silently, because the browser discards the cookie.

  • Do not flash from API endpoints. JSON clients have no page to show the message; return the error in the response body instead (see Error Handling).

  • Autoescaping protects the message (the example escaped the <b> of the title). Passing Markup(...) to flash() turns the escaping off for that message, and the Markup type survives the trip through the session. If you need markup, build it with Markup("<b>{}</b>").format(title), which escapes the arguments, never by concatenating user input into Markup or by using the safe filter on a flashed message.

  • Redirect status. The Post/Redirect/Get pattern uses a redirect after the POST. Flask 3.1 sends 302 from redirect(); the unreleased 3.2 changes the default to 303 See Other, which tells the client to switch to GET for every method. Nothing about flashing changes.

  • Flashing needs the session, so it is not cacheable (Vary: Cookie, see Vary: Cookie and Caching) and requires cookies to be enabled in the browser.

Testing Sessions

The test client keeps cookies, so a sequence of requests is a session. To prepare or inspect the session without going through the views, use client.session_transaction(). Inside the with block the session is a normal dictionary; the cookie is written back when the block exits. After a request, with client: keeps the request context open so you can read session directly.

import secrets

from flask import Flask, flash, redirect, session

app = Flask(__name__)
app.config["SECRET_KEY"] = secrets.token_hex()


@app.get("/me")
def me():
    return {"user": session.get("user")}


@app.post("/save")
def save():
    flash("Saved", "success")
    return redirect("/me")


client = app.test_client()

with client.session_transaction() as sess:       # arrange: be logged in without a login view
    sess["user"] = "alice"
assert client.get("/me").json == {"user": "alice"}

client.post("/save")
with client.session_transaction() as sess:       # assert on the raw flashed list
    assert sess["_flashes"] == [("success", "Saved")]

with client:                                     # keep the context to inspect the session after the call
    client.get("/me")
    assert session["user"] == "alice"

Flask 3.1.2 fixed follow_redirects=True in the test client so that the final state of session is correct. The testing page covers fixtures and the wider test strategy; the page about authentication and Flask-Login shows how to log in a test client.

Pitfalls and Security Checklist

  • Generate SECRET_KEY with secrets.token_hex(), load it from the environment, never commit it, and give each environment its own key.

  • The default session is signed, not encrypted: no secrets inside, and re-check privileges against the database.

  • Set SESSION_COOKIE_SECURE, SESSION_COOKIE_HTTPONLY (already true) and SESSION_COOKIE_SAMESITE="Lax" in production, and consider the __Host- name prefix.

  • Call session.clear() when a user logs in or out, and use a server-side store when you need real revocation.

  • Keep the cookie small (identifiers and flags), and store only types the serializer supports.

  • Reassign mutable values or set session.modified = True.

  • Give each local application its own SESSION_COOKIE_NAME, because cookies are not port-specific.

  • Sessions and cookies identify a browser; they do not replace CSRF protection for state-changing forms.

  • Cookies that are only needed for the experience are different from consent rules for tracking cookies; the strictly necessary session cookie is normally exempt, but legal requirements depend on jurisdiction and are outside the scope of the framework.

What Changed and Where Older Material Is Outdated

Version Change

Flask 0.10

Sessions serialise as JSON (with tagged types) instead of pickle

Flask 2.3

SESSION_COOKIE_DOMAIN no longer falls back to SERVER_NAME; the default is "not set"

Flask 2.3.2

Vary: Cookie is added when the session is accessed, modified or refreshed

Flask 3.1.0

SECRET_KEY_FALLBACKS for key rotation and SESSION_COOKIE_PARTITIONED (CHIPS) are added

Flask 3.1.1

The order in which fallback keys are tried is fixed (security release)

Flask 3.1.2

The session is correct after follow_redirects=True in the test client

Flask 3.1.3

in and len() on the session also mark it as accessed, so Vary: Cookie is set (security release)

Flask 3.2 (unreleased)

No change to the session API in the unreleased changelog; redirect() is announced to default to 303

Source: Flask: Changes

Where the Books Are Outdated

The cookbooks and Full-Stack Flask and React use sessions and flash() for login and feedback. The basic calls are still right; the following are not.

In the book Current Flask (3.1)

"The session cookie is encrypted" (Full-Stack Flask and React, authentication chapter)

It is signed, not encrypted; the content is readable, see How the Signed Cookie Works, and Why Signed Is Not Encrypted. Use a server-side store if the data must be private.

A literal key such as the placeholder string secret_key in the source of the example application

A leaked or guessable key lets anyone forge a session. Generate it with secrets.token_hex() and load it from the environment, see SECRET_KEY: Generating, Loading and Rotating.

Changing SECRET_KEY as the way to rotate keys

Since 3.1, SECRET_KEY_FALLBACKS keeps existing sessions valid during the rotation, see Rotating the Key with SECRET_KEY_FALLBACKS.

Cookie settings not mentioned, or set by hand with app.config["SESSION_COOKIE_SECURE"] only

Use the complete set (SECURE, HTTPONLY, SAMESITE, PARTITIONED, name prefix), see The SESSION_COOKIE_* Options.

flash() with no mention of category handling, size or the template

Categories, category_filter and with_categories, the cookie size limit and unread messages, see Message Flashing.

Server-side sessions with Flask-Session and SESSION_USE_SIGNER=True, pickle serialization

The signer is deprecated in 0.8 and the default serialization is msgpack; regenerate the id at login, see Server-Side Sessions with Flask-Session.

Redis-backed state described through Flask-Session without TTL discussion

Redis keys carry the permanent session lifetime as TTL; set PERMANENT_SESSION_LIFETIME deliberately.

Summary

  • flask.session is a per-request dictionary opened before routing and saved after the response; it needs SECRET_KEY and only detects changes made through its own methods.

  • The default session lives in a signed (not encrypted) cookie produced by ItsDangerous: readable by the user, limited to about 4 KB, valid until it expires, and forgeable by anyone who obtains the key.

  • Generate and store SECRET_KEY outside the source; rotate with SECRET_KEY_FALLBACKS (3.1).

  • session.permanent and PERMANENT_SESSION_LIFETIME set the lifetime, enforced by the signature age; the cookie attributes are the SESSION_COOKIE_* keys, including PARTITIONED and SAMESITE.

  • The session adds Vary: Cookie when accessed, which makes such responses uncacheable by shared caches.

  • Replace the machinery with a SessionInterface; use Flask-Session with Redis for private data, large data and revocation, and regenerate the identifier at login.

  • set_cookie and delete_cookie handle your own cookies; sign them with ItsDangerous when the value matters.

  • flash() and get_flashed_messages() give one-shot messages with categories across a redirect; test them with session_transaction() or the message_flashed signal.