Session cookies
Flask’s cookie defaults are half-set. Read from a stock application:
| Setting | Default |
|---|---|
SESSION_COOKIE_HTTPONLY |
True |
SESSION_COOKIE_SECURE |
False |
SESSION_COOKIE_SAMESITE |
None |
SESSION_COOKIE_NAME |
'session' |
PERMANENT_SESSION_LIFETIME |
31 days |
Which produces exactly this header:
Set-Cookie: session=eyJyb2xlIjoiYWRtaW4i...; HttpOnly; Path=/HttpOnly is there and is correct. There is no Secure and no SameSite attribute
at all.
The SameSite default is a genuine gap
Section titled “The SameSite default is a genuine gap”SESSION_COOKIE_SAMESITE = None in Flask’s config means “do not set the attribute” —
it is not the same as the cookie value SameSite=None. Flask omits the attribute
entirely.
That matters because it hands the decision to the browser. Modern browsers treat a
cookie with no SameSite as Lax, so in practice most traffic gets Lax behaviour — but
you are relying on a browser default rather than declaring one, and the default has
changed before.
Declaring it explicitly is the difference between “we are protected because Chrome
decided so” and “we asked for this”. It also matters for
CSRF, where Lax is the layer underneath the token.
SESSION_COOKIE_SECUREAdds the Secure attribute. Off by default, so the session cookie is sent over plaintext HTTP — one of the two attributes missing from Flask's shipped cookie.
- accepts
True|False- default
False- set in
app.config
SESSION_COOKIE_SAMESITEThe Python value None means OMIT the attribute entirely — it does not mean send SameSite=None. So the default cookie carries no SameSite at all, which is not the same as the permissive setting even though the literal looks identical. Flask is the odd one out here: Django defaults to "Lax" and Starlette to 'lax'.
- accepts
"Lax"|"Strict"|"None"|None- default
None- set in
app.config
SESSION_COOKIE_HTTPONLYOn by default, and the one cookie attribute Flask does set for you.
- accepts
True|False- default
True- set in
app.config
PERMANENT_SESSION_LIFETIME31 days, and it only applies once you set session.permanent = True. Because the session lives in the cookie there is nothing server-side to revoke, so this is the upper bound on how long a stolen cookie keeps working.
- accepts
timedelta(days=31)|any timedelta or seconds- default
datetime.timedelta(days=31)- set in
app.config
SESSION_COOKIE_NAMEGeneric enough to collide with another app on a shared parent domain.
- accepts
"session"|any cookie name- default
"session"- set in
app.config
SESSION_COOKIE_DOMAINNone 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.config
SESSION_COOKIE_PARTITIONEDOpts into CHIPS partitioned cookies. Note it requires SESSION_COOKIE_SECURE to be meaningful, so turning this on alone changes nothing.
- accepts
True|False- default
False- set in
app.config
SESSION_REFRESH_EACH_REQUESTRe-sends the permanent-session cookie on every response, sliding its expiry forward. It means an active session effectively never ages out, which is worth knowing when you set a lifetime expecting it to be absolute.
- accepts
True|False- default
True- set in
app.config
from datetime import timedelta
app.config.update( SESSION_COOKIE_SECURE=True, # not the default SESSION_COOKIE_HTTPONLY=True, # already the default; be explicit SESSION_COOKIE_SAMESITE="Lax", # Flask sets no attribute otherwise PERMANENT_SESSION_LIFETIME=timedelta(days=1),)curl -sI https://app.example.com/login | grep -i '^set-cookie'You want all four attributes present: Secure, HttpOnly, SameSite=Lax, Path=/.
Anything missing is a setting that did not take.
Read the config directly too, since a proxy can add attributes and mask a missing setting:
python3 -c "from app import appfor k in ('SESSION_COOKIE_SECURE','SESSION_COOKIE_HTTPONLY','SESSION_COOKIE_SAMESITE','PERMANENT_SESSION_LIFETIME'): print(f'{k:30}', app.config[k])"The contents are readable
Section titled “The contents are readable”Worth restating here because it changes what belongs in a session: the cookie is signed, not encrypted. The payload base64-decodes to plaintext without the key — see SECRET_KEY for the demonstration.
So HttpOnly stops script reading it and Secure stops the network seeing it,
but the user always can. Treat the session as a tamper-proof note the holder can read:
a user id belongs there, a permission set is visible, and a token from another system
does not belong there at all.
31 days is a long time
Section titled “31 days is a long time”PERMANENT_SESSION_LIFETIME only applies when a session is marked permanent
(session.permanent = True). Without that the cookie is a browser-session cookie and
expires when the browser does — which sounds shorter and is not, because browsers
restore sessions on restart.
Either way the signed cookie stays valid for its lifetime and there is no server-side revocation. Nothing on the server tracks issued sessions, so logging a user out clears their cookie and does nothing about a copy taken earlier.
If you need revocation — and an application with an admin role does — put a version number in the session, keep the current value on the user record, and reject a mismatch:
@app.before_requestdef check_session_version(): if "user_id" in session: user = load_user(session["user_id"]) if session.get("sv") != user.session_version: session.clear()Bumping user.session_version then invalidates every session for that user
immediately.
SESSION_COOKIE_SECURE=True means the cookie is not sent over plaintext HTTP at all, so
any part of the application still served over HTTP silently stops holding sessions. The
symptom is a login that appears to work and bounces straight back to the login form,
with nothing in the logs.
Behind a TLS-terminating proxy the application speaks plain HTTP internally. The cookie
is still set correctly — the attribute is unconditional — but any code branching on
request.scheme reads http, and url_for(..., _external=True) builds http:// URLs.
Fixing that is a proxy-header concern rather than a cookie one.
Shortening the lifetime logs users out sooner, which is a support question rather than a bug. Announce it or step it down over a couple of releases.
SameSite=Strict is the one to be careful with: it stops the cookie being sent on
top-level navigation into your site, so a user following a link from an email arrives
logged out. Lax is the right default for almost everything.
Related
Section titled “Related”- SECRET_KEY — what signs this cookie, and why its contents are readable
- CSRF protection — what SameSite covers, and what it does not
- Sessions: signed, not encrypted — how this compares with Django, FastAPI and Express, and what it means for what you may store