Session cookies: signed, not encrypted
Signing a session cookie makes it unforgeable. It does not make it unreadable. Those are different properties, and the gap between them decides what you are allowed to put in a session.
The part worth knowing before anything else: frameworks disagree about where the session lives, so the same sentence — “we use sessions” — describes two different exposures depending on the stack.
Measured
Section titled “Measured”The same two values, role="admin" and user_id=42, written into a session in each
framework, and the resulting cookie read back.
| Framework | Where the session lives by default | Cookie carries | Readable by the holder |
|---|---|---|---|
| Flask | the cookie — core has no other backend | the data | yes |
| FastAPI / Starlette | the cookie — SessionMiddleware’s only backend |
the data | yes |
| Django, default | server-side (db) |
an opaque id | no |
Django, signed_cookies |
the cookie — opt-in | the data | yes |
Express (express-session) |
server-side (MemoryStore) |
a signed id | no |
The two that put data in the cookie by default are Flask and Starlette. Django and Express both default to server-side storage, and Django will move the data into the cookie only if you select that backend deliberately.
What the readable ones look like
Section titled “What the readable ones look like”Flask, with session["role"] = "admin" and session["user_id"] = 42:
session=eyJyb2xlIjoiYWRtaW4iLCJ1c2VyX2lkIjo0Mn0.an4Urw.cvf-WeSRXUgi8CIZkOTmGWrzm9gDjango with SESSION_ENGINE = "django.contrib.sessions.backends.signed_cookies":
sessionid=eyJyb2xlIjoiYWRtaW4iLCJ1c2VyX2lkIjo0Mn0:1wuapB:V34Q1Voq_fz-Ua4CGx523MDR9jGUhxqoDifferent frameworks, different separators, same first segment. Base64-decode it — without the signing key — and both give back:
{"role":"admin","user_id":42}Starlette’s is the same shape with a shorter payload. The signature is the second and third segments; it is what stops the value being changed, and it plays no part in whether the value can be read.
And what the server-side ones look like
Section titled “And what the server-side ones look like”Express, same two values:
connect.sid=s%3ARprRDBuJ1Z45NbR6P6sO3SVGmvyMwu3V.AeWjL8i2BgafVGzhxwVwS8ud2FAsAvOtbZNAyUb7zxYThere is no admin and no 42 in there, because there is no data in there — the
random-looking string is a session id and the part after the dot is its signature. The
values live in the store on the server. Django’s default db backend behaves the same
way, with sessionid holding an opaque key.
What signing buys, precisely
Section titled “What signing buys, precisely”A signed cookie cannot be modified by its holder. Change one byte of
{"role":"admin"} to {"role":"owner"} and the signature no longer verifies, so the
framework discards the session rather than trusting it.
That is a real and sufficient guarantee for authorisation. If your code reads
session["role"] and acts on it, a cookie-backed session is defensible: the client
cannot promote themselves.
What it does not buy is confidentiality. Everyone who can see the cookie can see the
contents — the user, anything running in their browser that can reach the cookie, anyone
who captures the traffic if Secure is missing, and every log or analytics tool that
happens to record request headers.
So the rule is not “do not use cookie sessions”. It is: assume every value in a cookie-backed session is public, and put nothing there you would not put in a URL.
What that rules out
Section titled “What that rules out”Concretely, in a cookie-backed session: no password hashes, no API tokens or credentials for another system, no personal data you would have to disclose in a breach, and no internal record ids you would rather not have enumerated.
{"role": "admin"} is the interesting borderline case. It cannot be forged, so trusting
it is fine — but it does tell every session holder that a role field exists and hints at
what values it takes. That is not a vulnerability; it is a small piece of free
reconnaissance you chose to hand over.
The thing people most often get wrong here is storing a “remember me” token or a third-party access token in the session because it felt like the natural place. On Flask or Starlette that is publishing it.
Rotating the key logs everyone out
Section titled “Rotating the key logs everyone out”Because the cookie is the session on these frameworks, the signing key is what makes every existing cookie valid. Change it and every session in flight fails verification at once.
That is correct behaviour after a suspected key leak and an outage if you do it casually.
Both Flask and Django ship SECRET_KEY_FALLBACKS for exactly this — old keys still
accepted for verification, never used to sign — which turns a hard cutover into a
rotation you can stage. The defaults differ in a way worth noticing when you go looking
for it: Flask’s is None, Django’s is []. Plan the change either way; the server-side
backends do not have this problem, because invalidating a session there is a delete.
Verify
Section titled “Verify”Take a real session cookie from your own browser and decode the first segment. Nothing here needs the key, which is the point being demonstrated.
python3 - <<'EOF'import base64cookie = input("paste the session cookie value: ").strip()# Flask/Starlette separate with ".", Django's signed_cookies with ":"payload = cookie.replace(":", ".").split(".")[0]print(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4)))EOFIf that prints your session contents, you are on a cookie-backed session and everything above applies. If it prints nothing legible, the cookie is an id and your data is server-side.
Check which backend you are on directly:
# Djangopython3 -c "from django.conf import settings; print(settings.SESSION_ENGINE)"
# Flask — cookie-backed unless you have installed a server-side extensionpython3 -c "from app import app; print(type(app.session_interface).__name__)"Django’s default is django.contrib.sessions.backends.db. Seeing signed_cookies there
means the data is in the cookie, and it is worth confirming that was a decision rather
than something inherited from a tutorial.
Moving off a cookie session
Section titled “Moving off a cookie session”If you are on Flask or Starlette and the contents should not be public, the fix is a server-side store keyed by an opaque id — which is the shape Django and Express already give you by default.
Neither Flask nor Starlette ships one, so that is an extension or a small amount of code: generate a random id, put it in the cookie, keep the values in Redis or your database. The cookie attributes still matter afterwards, because the id is now the credential — see the per-framework pages below for what those default to, which in both cases is less than you would like.
Per framework
Section titled “Per framework”- Flask session cookies — cookie-backed by default;
SESSION_COOKIE_SAMESITEdefaults to PythonNone, meaning the attribute is omitted entirely - FastAPI session cookies —
SessionMiddlewareis cookie-only;https_onlyisFalseby default - Django cookie flags — server-side unless
SESSION_ENGINEsays otherwise, and the cookie attributes either way - Express session cookies — id in the cookie, data in the store;
cookie.secureis unset by default - Express session secret and store — the store holding that data, and what its default is unfit for