Skip to content

Session cookies

Severity: highApplies to: Flask 2.xApplies to: Flask 3.1.3Facts last verified 2026-08-13 against Flask 3.1.3

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.

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.

settings on this page
SESSION_COOKIE_SECURE

Adds 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
Read in: Flask 3.1.3 app.config, read from a running app; observed Set-Cookie has no Secure
SESSION_COOKIE_SAMESITE

The 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
Read in: Flask 3.1.3 app.config; observed Set-Cookie: session=...; HttpOnly; Path=/ — no SameSite attribute
SESSION_COOKIE_HTTPONLY

On by default, and the one cookie attribute Flask does set for you.

accepts
True | False
default
True
set in
app.config
Read in: Flask 3.1.3 app.config; observed in the Set-Cookie header
PERMANENT_SESSION_LIFETIME

31 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
Read in: Flask 3.1.3 app.config
SESSION_COOKIE_NAME

Generic enough to collide with another app on a shared parent domain.

accepts
"session" | any cookie name
default
"session"
set in
app.config
Read in: Flask 3.1.3 app.config
SESSION_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.config
Read in: Flask 3.1.3 app.config
SESSION_COOKIE_PARTITIONED

Opts 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
Read in: Flask 3.1.3 app.config
SESSION_REFRESH_EACH_REQUEST

Re-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
Read in: Flask 3.1.3 app.config
the fix
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),
)
verify it workedRun this in: http response
Terminal window
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:

Terminal window
python3 -c "
from app import app
for k in ('SESSION_COOKIE_SECURE','SESSION_COOKIE_HTTPONLY','SESSION_COOKIE_SAMESITE','PERMANENT_SESSION_LIFETIME'):
print(f'{k:30}', app.config[k])
"

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.

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_request
def 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.

before you ship this

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.