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 ( |
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.
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.
Source: Flask: Quickstart, Sessions
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], "...")
Source: Flask API: flask.session
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_KEYis not set,open_session()returnsNoneand Flask uses aNullSession: reading works (it is empty), but writing raises aRuntimeErrorthat 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
Source: Flask API: session.modified
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_KEYcan 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.
Source: Flask: Quickstart, Sessions
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 SESSION_COOKIE_* Options
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 |
|---|---|---|
|
|
The cookie name. Change it when two applications share a host (see below) or to use a |
|
|
|
|
|
|
|
|
Hides the cookie from JavaScript ( |
|
|
The browser sends the cookie only over HTTPS. Turn it on in production behind HTTPS |
|
|
Adds the |
|
|
|
|
31 days |
Lifetime of a permanent session and the maximum signature age |
|
|
Re-send the cookie on every response of a permanent session |
|
|
Werkzeug warns when a cookie header is larger than this; browsers may drop larger cookies silently. |
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:5000andlocalhost:5001share the same cookie jar, and each overwrites the other’ssessioncookie (the result is a visitor who is mysteriously logged out). Give each application its ownSESSION_COOKIE_NAMEduring 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_NAMEat the same time in that case, so the old cookies are simply ignored. -
SESSION_COOKIE_SECURE=Trueover 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=Strictbreaks the "arrive logged in from an e-mail link" case, because the first request from another site carries no cookie.Laxis 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"
Source: Flask API: flask.session
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
Source: OWASP: Session fixation
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
Source: Flask API: SessionInterface
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 |
|---|---|---|
|
|
Backend: |
|
none |
A |
|
|
Whether new sessions are permanent. Their stored lifetime is |
|
|
Prefix of the store keys, so several applications can share one Redis |
|
|
Number of random bytes behind the identifier |
|
|
How the data is serialised in the store: |
|
|
Signs the cookie value. Deprecated in 0.8 and slated for removal; a long random identifier does not need it |
|
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
Source: Flask-Session documentation
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 atimedelta) andexpires(a date) set the lifetime; with neither, the cookie lasts until the browser closes.max_agewins in modern browsers. -
secure,httponlyandsamesitemean the same as for the session cookie. Usehttponly=Falseonly 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.cookieslike 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}
Source: Werkzeug: Response.set_cookie
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.
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 <b>Dune</b>' in page # autoescaped
assert 'class="warning"' in page
assert "flashes" not in client.get("/books").text # shown once only
Source: Flask: Message Flashing
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=Truereturns(category, message)tuples instead of bare strings. The default isFalse, 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 "('message', 'Welcome back')" in text
Source: Flask API: get_flashed_messages
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")]
Source: Flask API: message_flashed
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). PassingMarkup(...)toflash()turns the escaping off for that message, and theMarkuptype survives the trip through the session. If you need markup, build it withMarkup("<b>{}</b>").format(title), which escapes the arguments, never by concatenating user input intoMarkupor by using thesafefilter on a flashed message. -
Redirect status. The Post/Redirect/Get pattern uses a redirect after the POST. Flask 3.1 sends
302fromredirect(); the unreleased 3.2 changes the default to303 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_KEYwithsecrets.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) andSESSION_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 |
|
Flask 2.3.2 |
|
Flask 3.1.0 |
|
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 |
Flask 3.1.3 |
|
Flask 3.2 (unreleased) |
No change to the session API in the unreleased changelog; |
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 |
A leaked or guessable key lets anyone forge a session. Generate it with |
Changing |
Since 3.1, |
Cookie settings not mentioned, or set by hand with |
Use the complete set ( |
|
Categories, |
Server-side sessions with Flask-Session and |
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 |
Source: Flask-Session documentation
Summary
-
flask.sessionis a per-request dictionary opened before routing and saved after the response; it needsSECRET_KEYand 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_KEYoutside the source; rotate withSECRET_KEY_FALLBACKS(3.1). -
session.permanentandPERMANENT_SESSION_LIFETIMEset the lifetime, enforced by the signature age; the cookie attributes are theSESSION_COOKIE_*keys, includingPARTITIONEDandSAMESITE. -
The session adds
Vary: Cookiewhen 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_cookieanddelete_cookiehandle your own cookies; sign them with ItsDangerous when the value matters. -
flash()andget_flashed_messages()give one-shot messages with categories across a redirect; test them withsession_transaction()or themessage_flashedsignal.