L22 · Sessions and Cookies
Module 4: Identity and Access · ~20 min · Lesson 22 of 71
🎯 What you'll learn
- Why HTTP needs sessions, and the difference between a session ID and a self-contained token
- What makes a session ID good, and how a server-side session store and timeouts work
- How session fixation and session hijacking work, and which defence stops which
- What
HttpOnly,Secure,SameSite,Domain, and the__Host-prefix each do for a session cookie
🔥 Why this matters
In December 2022, malware landed on a CircleCI engineer's laptop. The antivirus didn't catch it. It didn't need the engineer's password or their 2FA codes: it copied a session cookie for a "valid, 2FA-backed SSO session" (CircleCI's words) and used it from somewhere else. That one cookie led to production systems holding customer secrets. After someone logs in, the session cookie is their identity, so it deserves the same care as a password.
1. HTTP forgets you: sessions fix that
HTTP is stateless: each request stands alone, and the server doesn't remember the last one. You log in on request 1; request 2 arrives with nothing that says "same person." Something has to travel with every request to connect them. That something is a session: the server's record that "whoever presents this proof is customer C-20417, logged in at 09:14."
There are two ways to carry the proof:
SESSION ID (a reference) SELF-CONTAINED TOKEN (a value)
──────────────────────── ──────────────────────────────
browser holds: browser or app holds:
sid=Qm9iX3Rva2Vu... eyJhbGciOi... (signed claims:
random; means nothing sub, roles, exp)
by itself │
│ Cookie: sid=... │ Authorization: Bearer
▼ ▼
server looks it up: server checks the signature
sessions[hash(sid)] ──▶ (L17) and reads the claims;
{user: C-20417, exp: ...} no lookup needed
revoke: delete the record revoke: hard; wait for exp
or keep a deny-list (L25)
A session ID is a random, meaningless string that points to data the server keeps. A
self-contained token, such as a JWT, carries the data itself and is trusted because it's
signed. This lesson is about the first kind; JWTs get L25. Browser apps usually carry session IDs
in a cookie, a name-value pair the server sets with Set-Cookie and the browser sends back
in a Cookie header (L09).
2. What a good session ID looks like
A session ID is a bearer secret: whoever holds it is the user. So it has to be impossible to
guess. OWASP's Session Management Cheat Sheet asks for at least 64 bits of entropy; in practice,
generate 128 bits or more from a CSPRNG, as in L20: secrets.token_urlsafe(32) in Python, or
SecureRandom in Java. Never a counter, a timestamp, a user ID, or Math.random().
The data behind the ID lives in a server-side session store: Redis, a MongoDB collection, or the framework's own store. Two timeouts limit how long a stolen or forgotten ID stays useful:
- Idle timeout: the session ends after N minutes with no requests. OWASP suggests 2-5 minutes for high-value applications and 15-30 for low-risk ones.
- Absolute timeout: the session ends N hours after login, however active it is. OWASP gives 4-8 hours as an example for an all-day office application.
The server enforces both: a cookie's Expires is only a hint, and an attacker ignores it.
3. Session fixation: the attacker picks your ID
Here's the trick. Many apps hand out a session ID to anonymous visitors (for a cart, or a login form token) and simply attach the user to that same ID at login. If an attacker can make your browser use an ID they already know, your login upgrades their ID.
ATTACKER VICTIM'S BROWSER portal.example.com
│ │ │
│ 1. GET /login ─────────────────────────────────▶ │
│ ◀──── Set-Cookie: sid=AAA (anonymous session) ── │
│ │ │
│ 2. plant sid=AAA ─────▶ │ e.g. a cookie set by a │
│ in victim's browser │ sibling subdomain, or │
│ │ an old ?sid=AAA link │
│ │ 3. POST /login │
│ │ Cookie: sid=AAA ───▶ │
│ │ ◀── 200 OK (sid stays │
│ │ AAA, now C-20417) │
│ 4. GET /accounts Cookie: sid=AAA ─────────────▶ │
│ ◀───────────────────────── victim's accounts (!) │
This is session fixation: the attacker fixes the session ID before login, then shares the
session after it. The fix is one rule: issue a brand-new session ID at login (and at any
privilege change, such as step-up MFA), and delete the old one. Spring Security does this by
default (changeSessionId); in hand-rolled code, you have to.
4. Session hijacking: the attacker steals your ID
Session hijacking is using a session ID stolen after login. Each theft route has its own control:
HOW THE COOKIE GETS STOLEN WHAT STOPS IT
─────────────────────────────────────────── ────────────────
1. XSS: injected JS reads document.cookie ──▶ HttpOnly (L32)
2. Network: cookie sent over plain http:// ─▶ Secure + HSTS
3. Malware copies the browser's cookie ──▶ short sessions,
store (an infostealer) DBSC, re-auth
4. Phishing proxy relays your login and ──▶ phishing-
keeps the cookie (AitM, L21) resistant MFA
5. A sibling subdomain receives a cookie ──▶ no Domain,
scoped to Domain=example.com __Host- prefix
The cookie attributes from L09, now with their reasons:
HttpOnly: page JavaScript can't read the cookie, so an XSS bug can't simply send it away. (XSS can still make requests as the user from inside the page; HttpOnly limits the damage.)Secure: the browser sends the cookie only over HTTPS. Pair it with HSTS (Strict-Transport-Security, L37), a header telling the browser to always use HTTPS for your site, so there's never a plain-HTTP request to leak on.Domain: leave it out. Without it, the cookie is host-only: sent back only to the exact host that set it. WithDomain=example.com, it goes to every subdomain, including a forgottenpromo.example.comtaken over through a dangling DNS record (L08).__Host-prefix: a name starting with__Host-is accepted by the browser only withSecure,Path=/, and noDomain. It locks in the safe settings, and stops a sibling subdomain from planting a cookie with that name, which also blocks the fixation route in step 2.__Secure-is the weaker version: it only requiresSecure.
5. SameSite: when does the cookie go along?
SameSite controls whether the browser attaches a cookie to requests that another site
started. A "site" here is the scheme plus registrable domain, so https://portal.example.com and
https://blog.example.com are the same site.
| Value | Cookie sent on requests started by another site? |
|---|---|
Strict |
Never, not even when the user clicks a link to you (they arrive logged out) |
Lax |
Only on top-level navigations with safe methods (clicking a link: GET) |
None |
Always; the browser requires Secure with it |
This matters for CSRF (cross-site request forgery): a malicious page making the victim's
browser send a request to your site, cookies included. Lax blocks the classic hidden cross-site
POST form. Chromium-based browsers treat a cookie with no SameSite as Lax, with a short
exception for POSTs within two minutes of the cookie being set; other browsers differ, so set it
explicitly. CSRF gets its own lesson (L33).
🧪 Worked example: a login handler, vulnerable vs fixed
A customer portal sits behind Kong, which routes /login and /accounts to portal-svc on EKS.
Sessions are kept in a MongoDB sessions collection. Here's the first draft (Flask-style Python,
made-up names):
# VULNERABLE
@app.post("/login")
def login():
user = check_password(request.form) # L18
sid = request.cookies.get("sid") # (1) reuses old ID
if not sid:
sid = str(random.randint(0, 10**12)) # (2) guessable
sessions.update_one({"_id": sid},
{"$set": {"user": user.id}}, upsert=True) # (3) no expiry
resp = redirect("/accounts")
resp.set_cookie("sid", sid,
domain=".example.com") # (4) every subdomain
return resp # (5) no flags
- Keeps whatever ID the browser brought: session fixation.
randomisn't a CSPRNG, and 10^12 is only about 40 bits.- The session never expires on the server.
Domain=.example.comsends the cookie to every subdomain (the leading dot is ignored).- No
HttpOnly,Secure, orSameSite.
# FIXED
def h(sid): # store a hash, so a dump of
return sha256(sid.encode()).hexdigest() # the DB isn't live
@app.post("/login")
def login():
user = check_password(request.form)
old = request.cookies.get("__Host-sid")
if old:
sessions.delete_one({"_id": h(old)}) # kill pre-login
sid = secrets.token_urlsafe(32) # 256 bits, L20
now = datetime.now(timezone.utc)
sessions.insert_one({"_id": h(sid), "user": user.id,
"created": now, # absolute timeout
"lastSeen": now, # idle timeout
"expiresAt": now + timedelta(minutes=15)})
resp = redirect("/accounts")
resp.set_cookie("__Host-sid", sid, path="/",
secure=True, httponly=True, samesite="Lax")
return resp
Every request then looks up h(cookie) and rejects the session if now - lastSeen passes the
idle limit or now - created passes the absolute limit. The response header now reads:
Set-Cookie: __Host-sid=Xv3k...; Secure; HttpOnly; Path=/;
SameSite=Lax
🛠 Try it (optional, ~5 min)
A. Watch cookies on your own machine. Save this as cookie_demo.py in a scratch folder. It
listens only on 127.0.0.1:
import secrets
from http.server import BaseHTTPRequestHandler, HTTPServer
class H(BaseHTTPRequestHandler):
def do_GET(self):
sid = secrets.token_urlsafe(32)
self.send_response(200)
self.send_header("Set-Cookie", "theme=dark; Path=/")
self.send_header("Set-Cookie",
f"sid={sid}; Path=/; HttpOnly; SameSite=Lax")
self.send_header("Set-Cookie",
f"__Host-sid={sid}; Path=/; Secure; HttpOnly;"
" SameSite=Strict")
self.send_header("Set-Cookie",
"wide=1; Domain=localhost; Path=/")
self.end_headers()
got = self.headers.get("Cookie", "(none)")
self.wfile.write(f"You sent: {got}\n".encode())
HTTPServer(("127.0.0.1", 8000), H).serve_forever()
Run python3 cookie_demo.py, then in a second terminal:
$ curl -s -c jar.txt http://localhost:8000/
You sent: (none)
$ cat jar.txt
.localhost TRUE / FALSE 0 wide 1
#HttpOnly_localhost FALSE / TRUE 0 __Host-sid MFKmA4WJ...
#HttpOnly_localhost FALSE / FALSE 0 sid MFKmA4WJ...
localhost FALSE / FALSE 0 theme dark
$ curl -s -b jar.txt http://localhost:8000/
You sent: __Host-sid=MFKmA4WJ...; theme=dark; wide=1; sid=...
What to look for (output trimmed; curl 8.7.1 on macOS). Columns are host, "include subdomains",
path, Secure, expiry (0 means a browser-session cookie), name, value. #HttpOnly_ marks HttpOnly
cookies. Only wide, which set Domain, gets TRUE for subdomains. The jar has no SameSite
column: curl isn't a browser and ignores it. curl also kept the Secure cookie over plain HTTP
because it treats localhost as secure; on any other http:// host it would drop it.
B. The browser view. Open http://localhost:8000 in Chrome, then DevTools (Cmd+Opt+I) →
Application → Storage → Cookies → http://localhost:8000. In Firefox it's the
Storage tab → Cookies. Check the HttpOnly, Secure, and SameSite columns. Then in the
Console, type document.cookie: you should see only the cookies without HttpOnly. (Browsers
also treat localhost as a secure context; support for Domain=localhost varies.) Stop the
server with Ctrl+C.
🧠 Key takeaways
- After login, the session cookie is the user: protect it like a password.
- Session IDs: 128+ random bits from a CSPRNG, stored server-side (hashed), with idle and absolute timeouts enforced by the server.
- Session fixation is beaten by issuing a new ID at login and at every privilege change.
HttpOnlyvs XSS theft,Secure+ HSTS vs network theft, noDomain+__Host-vs sibling subdomains,SameSitevs cross-site requests.- None of these flags stops malware or a phishing proxy from taking a cookie; that needs the defences in the deep dives.
📖 Words to know
| Term | Meaning |
|---|---|
| Session | The server's record linking a series of requests to one logged-in user |
| Session ID | A random, meaningless string that points to session data kept on the server |
| Self-contained token | A signed token that carries the session data itself, such as a JWT (L25) |
| Idle timeout | Ends a session after a period with no activity |
| Absolute timeout | Ends a session a fixed time after login, regardless of activity |
| Session fixation | Getting a victim to log in with a session ID the attacker already knows |
| Session hijacking | Taking over a session by stealing its ID after login |
| HttpOnly | Cookie attribute that hides the cookie from page JavaScript |
| Secure | Cookie attribute that sends the cookie only over HTTPS |
| SameSite | Cookie attribute that controls sending the cookie on requests started by other sites |
| Host-only cookie | A cookie with no Domain, sent only to the exact host that set it |
__Host- prefix |
A cookie name prefix the browser accepts only with Secure, Path=/, and no Domain |
⏭ Next time
L23: OAuth 2.0 explained, the protocol behind "Sign in with..." and service-to-service tokens, and why the authorization code flow needs PKCE.
Stolen session cookies: why attackers skip the password
The shortcut
Passwords now come with MFA, breached-password checks, and rate limits (L18, L21). The session cookie issued after all that comes with none of them. Steal it, and you skip every login control at once. Security teams call this pass-the-cookie (MITRE ATT&CK lists it as T1539, Steal Web Session Cookie, and T1550.004, Web Session Cookie).
Two industrial ways to steal cookies
Infostealers are malware, often hidden in cracked software, fake installers, or "game mods," that grab the browser's saved passwords and cookie database and upload them within seconds. The stolen bundles, called logs, are sold on criminal markets by the thousand. CircleCI's 2022 breach (the hook of this lesson) is the textbook case.
AitM phishing kits (adversary-in-the-middle, L21), such as Evilginx, are reverse proxies on a lookalike domain. The victim sees the real login page, passed through the proxy, and completes real MFA. The proxy keeps the session cookie the real site sets at the end.
INFOSTEALER AitM PHISHING KIT
─────────── ─────────────────
[ laptop: fake installer ] [ victim ] ── types password
│ 1. reads browser's │ + MFA code
│ cookie store ▼
▼ ( proxy: login-example.co )
( attacker server ) ◀─ 2. upload │ relays both ways
│ ▼
│ 3. imports cookie ( real login.example.com )
▼ │ Set-Cookie: sid=...
[ attacker browser ] ── 4. GET ▼
/accounts as the victim proxy keeps sid ──▶ attacker
Notice what each defence covers. Phishing-resistant MFA (passkeys, FIDO2, L21) defeats the AitM kit, because the passkey won't sign for the lookalike domain. It does nothing against an infostealer, which steals the cookie after a perfectly good login on the real site.
Defence 1: make stolen cookies useless elsewhere (DBSC)
Device Bound Session Credentials (DBSC) is a browser feature that ties a session to a private key the browser keeps in hardware (the TPM on Windows), which can't be exported. Simplified:
BROWSER SERVER
│ login ──────────────────────────────▶ │
│ ◀── Secure-Session-Registration: ... │
│ makes key pair in TPM │
│ public key ─────────────────────────▶ │ ties key to session
│ ◀── short-lived cookie (e.g. 10 min) │
│ ...cookie expires... │
│ refresh request ────────────────────▶ │
│ ◀── challenge (403) │
│ challenge signed with TPM key ──────▶ │ checks signature
│ ◀── new short-lived cookie │
Malware can copy the cookie, but not the key, so the copy dies within minutes. Status as of November 2026: the origin trial ended in February 2026, and Google made DBSC generally available in Chrome 146 on Windows in spring 2026, with macOS promised in a later release. The spec is at the W3C, and Microsoft helped design it. It only protects sites that implement the registration and refresh endpoints, and other browsers' plans are unclear, so check current status before relying on it.
Defence 2: limit what a stolen cookie can do
- Short sessions for high-value apps (the idle and absolute timeouts from the main lesson).
- Re-authentication for sensitive actions, also called step-up: changing the payout bank account, adding a beneficiary, or exporting statements asks for a passkey or MFA again, right then. A stolen cookie can browse but can't move money.
- Anomaly signals: a session that suddenly appears from a new country, network, or browser fingerprint gets challenged or killed. OWASP notes that binding a session to an IP or User-Agent can be bypassed and can break legitimate users (mobile IPs change), so treat these as signals, not proof.
- On the endpoint: Chrome encrypts its cookie store with an app-bound key on Windows (since 2024), and EDR tools watch for processes reading browser profiles. Infostealers found ways around app-bound encryption within months, so this raises the bar rather than closing the door.
🧪 Worked example: a step-up check
SENSITIVE = {"/v1/payout-account", "/v1/beneficiaries"}
def require_recent_auth(session, path, max_age_s=300):
if path not in SENSITIVE:
return
age = now() - session["lastStrongAuth"]
if age.total_seconds() > max_age_s:
raise StepUpRequired(methods=["passkey"]) # 401
lastStrongAuth is set only when the user completes a passkey or MFA prompt, and the session ID is
regenerated at that moment (a privilege change, as in section 3).
🧠 Extra takeaways
- Stolen cookies skip passwords and MFA, which is why infostealers and AitM kits target them.
- Passkeys stop AitM phishing but not malware on the device.
- DBSC binds a session to a hardware key; it's new, Chrome-on-Windows first, and needs server work.
- Step-up re-auth for money-moving actions limits what any stolen session can do.
| Term | Meaning |
|---|---|
| Pass-the-cookie | Using a stolen session cookie to act as a user without logging in |
| Infostealer | Malware that harvests saved passwords, cookies, and tokens from a device |
| AitM phishing kit | A reverse proxy that relays a real login and keeps the resulting session |
| DBSC | Device Bound Session Credentials: sessions refreshed with proof of a hardware-held key |
| Step-up authentication | Asking for a fresh strong login before a sensitive action |
Logout, timeouts and revocation done right
The logout that doesn't
A common "logout" button runs one line of JavaScript, or returns Set-Cookie: sid=; Max-Age=0.
The browser forgets the cookie, but the session record is still alive on the server. Anyone who
copied the cookie earlier, from a shared PC, a proxy log, or an infostealer, can keep using it.
OWASP puts it plainly: deleting a cookie alone doesn't invalidate a stolen copy. Logout must
delete the session on the server.
Two clocks, one session
login activity... quiet 15 min 8 h after login
│ ▲ ▲ ▲ ▲ ▲ ├───────────────┤ │
▼ │ │ │ │ │ │ idle timeout │ ▼
├──┴───┴───┴───┴──────┴──┴───────────────X X
│ ▲ ▲
│ each request resets the idle clock │ │
│ ends here if always ends
│ user is idle here (absolute)
The idle clock resets on each request; the absolute clock never does. Without an absolute limit, a script polling every minute keeps a stolen session alive forever.
Revocation: when to kill sessions
Kill one session at logout. Kill all of a user's sessions when they change their password,
reset MFA, report a lost phone, or when support or fraud teams suspect takeover. For that you need
to find every session by user, so index the store on user. A "log out of all devices" button
built on the same query is cheap and customers of a financial portal expect it.
🧪 Worked example: a MongoDB session store
// expire records automatically; TTL index on expiresAt
db.sessions.createIndex({ expiresAt: 1 },
{ expireAfterSeconds: 0 })
db.sessions.createIndex({ user: 1 }) // for "kill all"
def logout(req):
sid = req.cookies.get("__Host-sid")
if sid:
sessions.delete_one({"_id": h(sid)}) # server side
resp = redirect("/")
resp.delete_cookie("__Host-sid", path="/",
secure=True, httponly=True, samesite="Lax")
return resp
def kill_all(user_id): # password change, fraud
sessions.delete_many({"user": user_id})
One catch: MongoDB's TTL monitor runs about once a minute, so expired records can linger briefly.
Always check expiresAt (and the idle and absolute clocks) in code on each request; treat the TTL
index as cleanup, not as the security control. Redis EXPIRE is more precise, but the same rule
holds.
If Kong holds the session
Kong's session plugin can manage the session for an auth plugin (often openid-connect) instead
of your service. Its defaults, per Kong's current plugin reference, are already strict:
cookie_secure and cookie_http_only true, cookie_same_site: Strict, idling_timeout 900 s,
rolling_timeout 3600 s, absolute_timeout 86400 s. Two settings deserve a look:
plugins:
- name: session
config:
cookie_name: __Host-portal # default name is "session"
absolute_timeout: 28800 # 8 h, not the 24 h default
storage: kong # default is "cookie"
With the default storage: cookie, the encrypted session data lives in the cookie itself, and
Kong keeps nothing to delete, so a copied cookie stays valid until its timeouts run out. With
storage: kong, the data lives in Kong's database and the cookie only references it, which gives
you something to revoke. Logout is a POST or DELETE carrying session_logout (configurable).
Option names changed between Kong versions, so check the reference for yours.
Stateless tokens have the same problem, worse
A signed JWT can't be "deleted"; it's valid until exp. That's why access tokens are kept short
(minutes) and paired with a refresh token the server can revoke. L25 covers it in depth.
🛠 Try it (~5 min)
In a portal you build or test (in a dev environment), log in, copy the session cookie value from
DevTools, and log out. Then replay it on your own dev service:
curl -s -b '__Host-sid=<copied value>' https://portal.dev.example.com/accounts.
If you get data back, logout only cleared the browser. Only do this against systems you own.
🧠 Extra takeaways
- Logout deletes the session on the server; clearing the cookie is just tidying up.
- Use both an idle and an absolute timeout, enforced in code on every request.
- Index sessions by user so you can revoke all of them after a password change or suspected fraud.
- Kong's session plugin with
storage: cookiehas nothing server-side to revoke;storage: kongdoes.
| Term | Meaning |
|---|---|
| Server-side invalidation | Deleting or disabling the session record so the ID stops working everywhere |
| TTL index | A MongoDB index that deletes documents after a time, run by a background task about every 60 s |
| Revocation | Ending a session or token before its natural expiry |
| Log out everywhere | Revoking all of a user's sessions at once |
The quiz for this lesson and the Tests section need JavaScript.