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.

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 a SameSite attribute as Lax, 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.

Two name prefixes make browsers enforce attributes for you:

  • __Secure-name: must be set with Secure from an HTTPS page.
  • __Host-name: must be Secure, must have Path=/, and must not have a Domain attribute, 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_urlsafe uses a cryptographically secure generator. IDs from random, 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-Cookie was rejected, with the reason (for example, SameSite=None without Secure).
  • curl with a cookie jar: curl -c jar.txt -b jar.txt -d 'user=a&pass=b' https://app.test/login then curl -b jar.txt https://app.test/account. The jar file shows exactly what was stored.
  • Cookie not sent? Check, in order: Secure on a plain-HTTP site, Domain/Path mismatch, SameSite on a cross-site request, expiry, and, for fetch, whether credentials: "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=Lax or Strict.
  • 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 Origin in addition to SameSite.
  • JWT verification pins algorithms and checks exp, aud and iss.