Skip to content

Session cookies

Severity: highApplies to: Starlette 1.xApplies to: FastAPI 0.100+Facts last verified 2026-08-13 against Starlette 1.6.0 · FastAPI 0.141.1

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.

Setting one key and reading the resulting cookie:

request.session["role"] = "admin"
set-cookie: session=eyJyb2xlIjogImFkbWluIn0=.amofpA.xU4v99iZIXJur9BVtPT85DpRLwA;
path=/; Max-Age=1209600; httponly; samesite=lax

The 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.

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.

settings on this page
secret_keyoption

Signing 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, ...)
Read in: starlette 1.6.0 starlette/middleware/sessions.py — SessionMiddleware.__init__, no default; reproduced
https_onlyoption

Adds 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, ...)
Read in: starlette 1.6.0 starlette/middleware/sessions.py — SessionMiddleware.__init__ signature
max_ageoption

Cookie 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, ...)
Read in: starlette 1.6.0 starlette/middleware/sessions.py — SessionMiddleware.__init__ signature; reproduced as Max-Age=1209600
same_siteoption

SameSite 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, ...)
Read in: starlette 1.6.0 starlette/middleware/sessions.py — SessionMiddleware.__init__ signature; reproduced as samesite=lax
session_cookieoption

Cookie 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, ...)
Read in: starlette 1.6.0 starlette/middleware/sessions.py — SessionMiddleware.__init__ signature
pathoption

Cookie path scope.

accepts
"/" | any path
default
"/"
set in
app.add_middleware(SessionMiddleware, ...)
Read in: starlette 1.6.0 starlette/middleware/sessions.py — SessionMiddleware.__init__ signature
domainoption

Cookie 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, ...)
Read in: starlette 1.6.0 starlette/middleware/sessions.py — SessionMiddleware.__init__ signature
the fix
import os
from 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.

verify it workedRun this in: http response
Terminal window
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:

Terminal window
python3 - <<'EOF'
import base64, sys
cookie = input("paste the session cookie value: ").strip()
payload = cookie.split(".")[0]
print(base64.b64decode(payload + "=" * (-len(payload) % 4)))
EOF

Whatever 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.

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.

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.

before you ship this

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.