Skip to main content

How Secure Cookie Sessions Work

Flask's default session management is built on the principle of client-side storage. Instead of storing session data in a server-side database or cache, Flask serializes the session dictionary into a cryptographically signed cookie. This approach simplifies deployment by keeping the application stateless, though it introduces specific security and implementation considerations.

The Session Lifecycle

The session lifecycle is managed by the SecureCookieSessionInterface in src/flask/sessions.py. This class handles the transition between the raw cookie sent by the client and the session object used in application code.

Loading the Session

When a request begins, the RequestContext lazily calls open_session. The interface retrieves the cookie (defaulting to the name "session") and uses itsdangerous.URLSafeTimedSerializer to verify the signature.

def open_session(self, app: Flask, request: Request) -> SecureCookieSession | None:
s = self.get_signing_serializer(app)
if s is None:
return None
val = request.cookies.get(self.get_cookie_name(app))
if not val:
return self.session_class()
max_age = int(app.permanent_session_lifetime.total_seconds())
try:
data = s.loads(val, max_age=max_age)
return self.session_class(data)
except BadSignature:
return self.session_class()

If the signature is invalid or the cookie is missing, a new, empty SecureCookieSession is returned.

Saving the Session

At the end of the request, save_session is called. If the session has been modified, the interface re-serializes the data, signs it, and sets a new cookie on the response. It also adds a Vary: Cookie header if the session was accessed, ensuring that downstream caches (like CDNs) do not serve session-specific content to the wrong users.

Change Tracking and the Modified Flag

The SecureCookieSession class is more than a standard dictionary; it inherits from werkzeug.datastructures.CallbackDict. This allows it to automatically track when its contents are changed.

class SecureCookieSession(CallbackDict[str, t.Any], SessionMixin):
modified = False

def __init__(self, initial: c.Mapping[str, t.Any] | None = None) -> None:
def on_update(self: te.Self) -> None:
self.modified = True

super().__init__(initial, on_update)

When you perform an operation like session["user_id"] = 1, the on_update callback triggers, setting session.modified = True. This flag is critical because save_session will only update the client's cookie if modified is true.

The Nested Object Tradeoff

A significant constraint of this implementation is that it only tracks top-level changes. If the session contains a mutable object, such as a list, modifying that list directly will not trigger the on_update callback:

# This will NOT trigger a cookie update automatically
session["items"].append("new_item")

# You must manually flag the session as modified
session.modified = True

Security through Signing

Flask sessions are signed, not encrypted. The data is encoded in Base64 and can be read by anyone who has access to the cookie. However, the signature prevents the data from being tampered with.

The SecureCookieSessionInterface uses itsdangerous to create a URLSafeTimedSerializer. This serializer uses the application's SECRET_KEY to generate a HMAC signature.

Key Rotation

The implementation supports key rotation via SECRET_KEY_FALLBACKS. When verifying a cookie, the interface will attempt to use the current SECRET_KEY first, then iterate through any fallbacks defined in the app configuration.

def get_signing_serializer(self, app: Flask) -> URLSafeTimedSerializer | None:
if not app.secret_key:
return None

keys: list[str | bytes] = []
if fallbacks := app.config["SECRET_KEY_FALLBACKS"]:
keys.extend(fallbacks)

keys.append(app.secret_key)
return URLSafeTimedSerializer(
keys,
salt=self.salt,
serializer=self.serializer,
# ...
)

Serialization with Tagged JSON

Standard JSON cannot represent certain Python types like datetime objects or UUIDs. To solve this, Flask uses a TaggedJSONSerializer (found in src/flask/json/tag.py). This serializer "tags" non-standard types with a special key so they can be reconstructed accurately when the session is loaded.

For example, a UUID is serialized as {" u": "uuid-hex-string"}. This ensures that session["id"] remains a UUID object across requests rather than becoming a plain string.

The NullSession Safety Valve

If an application developer forgets to set a SECRET_KEY, Flask cannot safely sign cookies. Instead of failing silently or allowing insecure sessions, the interface returns a NullSession.

class NullSession(SecureCookieSession):
def _fail(self, *args: t.Any, **kwargs: t.Any) -> t.NoReturn:
raise RuntimeError(
"The session is unavailable because no secret "
"key was set. Set the secret_key on the "
"application to something unique and secret."
)

__setitem__ = __delitem__ = clear = pop = update = _fail

The NullSession allows read access (which will always be empty) but raises a RuntimeError the moment any code attempts to write to it. This design choice forces developers to address security requirements early in the development cycle.