Skip to content

Session cookies: signed, not encrypted

Severity: mediumApplies to: Flask 3.1.3Applies to: Starlette 1.6.0 / FastAPI 0.141.1Applies to: Django 6.1Applies to: express-session 1.19.0Facts last verified 2026-08-13 against Flask 3.1.3 · Starlette 1.6.0 · Django 6.1 · express-session 1.19.0

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.

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 cookieSessionMiddleware’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.

Flask, with session["role"] = "admin" and session["user_id"] = 42:

session=eyJyb2xlIjoiYWRtaW4iLCJ1c2VyX2lkIjo0Mn0.an4Urw.cvf-WeSRXUgi8CIZkOTmGWrzm9g

Django with SESSION_ENGINE = "django.contrib.sessions.backends.signed_cookies":

sessionid=eyJyb2xlIjoiYWRtaW4iLCJ1c2VyX2lkIjo0Mn0:1wuapB:V34Q1Voq_fz-Ua4CGx523MDR9jGUhxqo

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

Express, same two values:

connect.sid=s%3ARprRDBuJ1Z45NbR6P6sO3SVGmvyMwu3V.AeWjL8i2BgafVGzhxwVwS8ud2FAsAvOtbZNAyUb7zxY

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

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.

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.

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.

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.

Terminal window
python3 - <<'EOF'
import base64
cookie = 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)))
EOF

If 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:

Terminal window
# Django
python3 -c "from django.conf import settings; print(settings.SESSION_ENGINE)"
# Flask — cookie-backed unless you have installed a server-side extension
python3 -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.

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.