Cookies & HTTP Sessions
On this page
Giving a Stateless Protocol a Memory#
HTTP is stateless by design: every request stands alone. Yet every login-protected site remembers who you are across hundreds of requests. That continuity comes from cookies, and from the session abstraction built on top of them. Getting the details right is the difference between a secure login and an account-takeover bug.
How Cookies Work#
A server asks the client to store a cookie with a response header:
Set-Cookie: session_id=Yx3kQ9vT2mB8; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=3600
The browser stores it and attaches it automatically to every later request that matches the cookie's scope:
Cookie: session_id=Yx3kQ9vT2mB8; theme=dark
Notice the asymmetry: the server sends one Set-Cookie header per cookie, with attributes; the client sends one Cookie header containing only name=value pairs. The server never learns a cookie's attributes or expiry from the request, so it cannot tell which of two same-named cookies it is looking at.
Cookie Attributes#
| Attribute | Effect | Guidance |
|---|---|---|
Domain |
which hosts receive it | Omit it to make a host-only cookie. Setting Domain=example.com sends the cookie to every subdomain too. |
Path |
URL prefix that receives it | Not a security boundary; pages on other paths of the same origin can still read it via script. |
Expires / Max-Age |
lifetime | Without either, it is a session cookie, deleted when the browser session ends (browsers that restore sessions may keep it). Max-Age wins if both are set. |
HttpOnly |
invisible to JavaScript (document.cookie) |
Always set for session cookies; blunts session theft through XSS. |
Secure |
only sent over HTTPS | Always set in production. |
SameSite |
cross-site sending rules | Lax for most session cookies, Strict for high-value ones, None only when needed (requires Secure). |
SameSite deserves precision. A request is cross-site when the page that triggers it has a different registrable domain (for example evil.test triggering a request to bank.example).
Strict: never sent on cross-site requests, not even when a user clicks a link to your site from an email. The user appears logged out on arrival.Lax: sent on top-level navigations using safe methods (clicking a link, a GET form), but not on cross-site POSTs, iframes, or image and fetch sub-requests. Chromium-based browsers treat cookies without aSameSiteattribute asLax, but not every browser does, so always set it explicitly.None: always sent, for legitimate cross-site uses such as embedded widgets and some single sign-on flows.
Lax blocks the classic CSRF attack (a hidden cross-site form POST), but not attacks from a same-site origin such as a compromised subdomain, so state-changing endpoints should still verify a CSRF token or the Origin header.
Cookie Prefixes#
Two name prefixes make browsers enforce attributes for you:
__Secure-name: must be set withSecurefrom an HTTPS page.__Host-name: must beSecure, must havePath=/, and must not have aDomainattribute, so it is locked to exactly one host.
__Host- stops a sibling subdomain (say, a user-content host) from planting or overwriting your session cookie, which is the basis of session fixation across subdomains. Python's standard library can build and parse these headers:
from http.cookies import SimpleCookie
cookie = SimpleCookie()
cookie["__Host-session"] = "Yx3kQ9vT2mB8"
morsel = cookie["__Host-session"]
morsel["path"] = "/"
morsel["secure"] = True
morsel["httponly"] = True
morsel["samesite"] = "Lax"
morsel["max-age"] = 3600
print(cookie.output())
incoming = SimpleCookie()
incoming.load("theme=dark; __Host-session=Yx3kQ9vT2mB8")
print({k: m.value for k, m in incoming.items()})
Set-Cookie: __Host-session=Yx3kQ9vT2mB8; HttpOnly; Max-Age=3600; Path=/; SameSite=Lax; Secure
{'theme': 'dark', '__Host-session': 'Yx3kQ9vT2mB8'}
Browsers limit each cookie to roughly 4 KB and cap the number per domain, so cookies are for identifiers and small preferences, not data.
Server-Side Sessions#
The most common pattern keeps only an opaque, random session ID in the cookie; the real state lives on the server. A minimal, framework-agnostic version:
import secrets, time
SESSIONS = {} # in production: Redis or a database table
IDLE_TIMEOUT = 30 * 60 # seconds of inactivity allowed
ABSOLUTE_TIMEOUT = 12 * 3600
def create_session(user_id):
sid = secrets.token_urlsafe(32) # ~256 bits of randomness
now = time.time()
SESSIONS[sid] = {"user_id": user_id, "created": now, "seen": now}
return sid
def load_session(sid):
data = SESSIONS.get(sid)
if data is None:
return None
now = time.time()
if now - data["seen"] > IDLE_TIMEOUT or now - data["created"] > ABSOLUTE_TIMEOUT:
SESSIONS.pop(sid, None) # expired: delete, do not extend
return None
data["seen"] = now
return data
def login(old_sid, user_id):
SESSIONS.pop(old_sid, None) # never keep a pre-login ID
return create_session(user_id) # new ID -> new Set-Cookie
def logout(sid):
SESSIONS.pop(sid, None) # server-side revocation is instant
Why each line matters:
secrets.token_urlsafeuses a cryptographically secure generator. IDs fromrandom, timestamps or user IDs can be guessed.login()issues a new ID after authentication. Keeping the anonymous ID would allow session fixation: an attacker plants a known ID in the victim's browser, the victim logs in, and the attacker's copy of the ID is now authenticated.- Both an idle and an absolute timeout exist, so a tab left open for a week does not stay logged in.
logout()deletes the record, so the cookie becomes worthless immediately, even if someone copied it.
The cost: every server behind a load balancer must reach the same session store, or you need sticky sessions. A shared Redis is the usual answer.
Stateless Sessions and JWTs#
The alternative puts the state in the token itself, signed by the server. A JSON Web Token (JWT) has three base64url parts: header, payload and signature. The server verifies the signature and trusts the payload without a lookup. The payload is encoded, not encrypted, as anyone can check:
import base64, json
token = ("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9."
"eyJzdWIiOiJ1c2VyXzQyIiwicm9sZSI6ImVkaXRvciIsImV4cCI6MTc5MDAwMDAwMH0."
"c2lnbmF0dXJlLWJ5dGVzLWdvLWhlcmU")
def b64url_decode(part: str) -> bytes:
return base64.urlsafe_b64decode(part + "=" * (-len(part) % 4)) # restore padding
header, payload, _sig = token.split(".")
print(json.loads(b64url_decode(header)))
print(json.loads(b64url_decode(payload)))
{'alg': 'HS256', 'typ': 'JWT'}
{'sub': 'user_42', 'role': 'editor', 'exp': 1790000000}
Never put secrets or personal data you would not show the user in a JWT payload. When verifying, use a maintained library (for example PyJWT's jwt.decode(token, key, algorithms=["HS256"])) and always pin the accepted algorithms, because historic bugs let attackers switch alg to none or confuse HMAC and RSA keys.
| Server-side session | JWT | |
|---|---|---|
| Revocation | instant (delete the record) | hard until exp; needs a denylist or short lifetimes |
| Lookup per request | yes (fast with Redis) | no |
| Size on the wire | ~40 bytes | often hundreds of bytes |
| Data visible to client | no | yes (base64) |
| Good fit | browser logins for one application | service-to-service calls, short-lived API access tokens |
A common hybrid issues a short-lived access token (minutes) plus a longer-lived refresh token stored server-side. Refresh tokens should rotate on every use: if an old refresh token is ever presented again, someone copied it, and the whole token family should be revoked.
Where you store the token matters too. localStorage is readable by any script on the page, so one XSS bug leaks every token. An HttpOnly; Secure; SameSite cookie cannot be read by script, which is why many teams deliver JWTs in cookies anyway.
Debugging Cookies#
- Browser DevTools, Application (or Storage) tab: lists every cookie with domain, path, expiry and flags. The Network tab shows a warning icon when a
Set-Cookiewas rejected, with the reason (for example,SameSite=NonewithoutSecure). - curl with a cookie jar:
curl -c jar.txt -b jar.txt -d 'user=a&pass=b' https://app.test/loginthencurl -b jar.txt https://app.test/account. The jar file shows exactly what was stored. - Cookie not sent? Check, in order:
Secureon a plain-HTTP site,Domain/Pathmismatch,SameSiteon a cross-site request, expiry, and, forfetch, whethercredentials: "include"was set. - Logged out randomly behind a load balancer? The session store is not shared, or instances use different secret keys to sign session cookies.
Cookies in Automation#
Scripts and scrapers juggle cookies too. In Python, a requests.Session() or httpx.Client() keeps a cookie jar across requests automatically, so a login POST followed by authenticated GETs just works. Persist the jar between runs rather than logging in repeatedly, keep the same outbound IP for a session's lifetime, and respect the site's terms. The Cookies and State and Authentication & Sessions lessons cover this side in depth. From both sides of the wire the rule is the same: whoever holds the session cookie is the user, so protect it accordingly.
Checklist#
- Session cookie is
__Host-prefixed,HttpOnly,Secure,SameSite=LaxorStrict. - New session ID on login and on privilege change; server-side deletion on logout.
- Idle and absolute timeouts enforced on the server, not just via
Max-Age. - State-changing endpoints check a CSRF token or
Originin addition to SameSite. - JWT verification pins algorithms and checks
exp,audandiss.
Check your understanding
4 quick questions on this lesson. Get them all right and the lesson is marked complete.