Session cookies
Starlette’s SessionMiddleware stores the session in the cookie, signed with
itsdangerous. Signed is not encrypted, and the difference decides what you are
allowed to put in there.
Observed
Section titled “Observed”Setting one key and reading the resulting cookie:
request.session["role"] = "admin"set-cookie: session=eyJyb2xlIjogImFkbWluIn0=.amofpA.xU4v99iZIXJur9BVtPT85DpRLwA; path=/; Max-Age=1209600; httponly; samesite=laxThe first segment is base64. Decoding it without the secret key:
{"role": "admin"}The signature stops the client changing role to something else. It does nothing to
stop them reading it. Anyone with the cookie — the user, a browser extension, anyone
who sees the traffic — can read every value in the session.
The defaults
Section titled “The defaults”Read from the middleware signature:
| Parameter | Default |
|---|---|
session_cookie |
'session' |
max_age |
1209600 (14 days) |
path |
'/' |
same_site |
'lax' |
https_only |
False |
domain |
None |
same_site='lax' is a good default and does useful CSRF work. https_only=False is
the one to change: without it the cookie has no Secure attribute and the browser
will send it over plaintext HTTP.
secret_keyoptionSigning key. Required — there is no default, so the middleware cannot start unkeyed. Note what it does and does not buy: it makes the cookie unforgeable, not unreadable. The payload stays base64 and anyone holding the cookie can decode it without this key.
- accepts
any str or bytes- set in
app.add_middleware(SessionMiddleware, ...)
https_onlyoptionAdds the Secure attribute. Off by default, so the session cookie is sent over plaintext HTTP unless you turn it on — the one default on this middleware that needs changing for any real deployment.
- accepts
True|False- default
False- set in
app.add_middleware(SessionMiddleware, ...)
max_ageoptionCookie lifetime, defaulting to 14 days. It also bounds how long a stolen cookie stays valid, because the signature carries a timestamp — there is no server-side session to revoke.
- accepts
1209600|any int, seconds|None (session cookie)- default
1209600- set in
app.add_middleware(SessionMiddleware, ...)
same_siteoptionSameSite attribute. Lax by default, which is a reasonable CSRF baseline given Starlette ships no CSRF protection of its own.
- accepts
"lax"|"strict"|"none"- default
"lax"- set in
app.add_middleware(SessionMiddleware, ...)
session_cookieoptionCookie name. Worth knowing because the default is generic enough to collide with another app on a shared parent domain.
- accepts
"session"|any cookie name- default
"session"- set in
app.add_middleware(SessionMiddleware, ...)
pathoptionCookie path scope.
- accepts
"/"|any path- default
"/"- set in
app.add_middleware(SessionMiddleware, ...)
domainoptionCookie Domain. None keeps the cookie host-only, which is the tighter behaviour — setting a parent domain shares the session with every subdomain, including any you do not control.
- accepts
None|".example.com"- default
None- set in
app.add_middleware(SessionMiddleware, ...)
import osfrom starlette.middleware.sessions import SessionMiddleware
app.add_middleware( SessionMiddleware, secret_key=os.environ["SESSION_SECRET"], # KeyError if unset, deliberately https_only=True, # adds Secure; not the default same_site="lax", max_age=60 * 60 * 24, # 1 day, not 14)os.environ[...] rather than os.getenv(...) so a missing secret stops the process
instead of starting one with None as the signing key.
curl -sI https://app.example.com/login | grep -i '^set-cookie'The cookie must carry Secure, HttpOnly and SameSite. Then decode the payload to
see what you are shipping to the client:
python3 - <<'EOF'import base64, syscookie = input("paste the session cookie value: ").strip()payload = cookie.split(".")[0]print(base64.b64decode(payload + "=" * (-len(payload) % 4)))EOFWhatever prints is readable by the cookie holder. If that includes an email address, a role, an internal id or anything you would not put in a URL, move it server-side.
What belongs in there
Section titled “What belongs in there”A user identifier and not much else. Treat the session cookie as a public, tamper-proof note — the contents are visible, the contents cannot be forged.
Specifically not: password hashes, API tokens, anything from another system’s
credentials, internal record ids you would rather not enumerate, or permission sets you
intend to trust. That last one is subtle — {"role": "admin"} cannot be edited by
the client, so trusting it is defensible, but it does tell every session holder that a
role field exists and what values it takes.
For anything larger or more sensitive, use a server-side store keyed by an opaque id. Starlette ships only the cookie backend, so that is a library or a small amount of code you write.
Rotating the secret logs everyone out
Section titled “Rotating the secret logs everyone out”secret_key verifies every existing cookie. Change it and every session in flight
fails its signature check and is discarded.
That is the correct behaviour after a suspected leak, and it is an outage if you do it casually on a Monday morning. Plan it: rotate during a low-traffic window, or accept the mass logout deliberately.
https_only=True means the cookie is not sent over plaintext HTTP at all — so if
any part of your application is still served over HTTP, sessions silently stop working
there. The symptom is a login that appears to succeed and then bounces straight back
to the login page, with no error anywhere.
Behind a TLS-terminating proxy this gets one step more subtle: the application speaks
plain HTTP to the proxy, so it may believe the connection is insecure even though the
browser’s is not. The cookie is set correctly either way — https_only only controls
the attribute — but any code of yours that branches on request.url.scheme will read
http unless the forwarded headers are being honoured. That is
proxy headers, and it is worth reading before you debug this
one.
Shortening max_age from the 14-day default logs users out sooner, which is a support
question rather than a bug. Announce it, or ramp it down over a couple of releases.
Related
Section titled “Related”- Proxy headers — why the scheme the application sees may not be the scheme the browser used
- CSRF protection — what
same_sitedoes and does not cover - Sessions: signed, not encrypted — how this compares with Django, Flask and Express, and what it means for what you may store