Session & Auth Tokens

Chapter 7
Session & Auth Tokens
Cookies vs JWT vs server-side sessions, and where each piece of auth-related data is actually safe to store

Every chapter so far has been building toward this one — cookies (Ch1-3), security attributes (Ch2), cross-origin behaviour (Ch5), and headers (Ch6) all matter most in exactly this context: keeping a user logged in safely. This chapter compares the two dominant approaches to web authentication, and answers the question that trips up almost every developer at some point: where should a token actually live?

Server-Side Sessions — The Traditional Approach

The server creates a session record (in memory, a database, or Redis) and gives the browser only a short, random session ID — the actual user data and permissions stay entirely on the server, looked up by that ID on every request.

// What the browser actually holds — just an opaque ID, no user data
Set-Cookie: session_id=a1b2c3d4e5f6; Secure; HttpOnly; SameSite=Lax

// What the server holds, looked up by that ID:
{
  "user_id": 42,
  "role": "admin",
  "expires": "2026-07-01T12:00:00Z"
}

JWT (JSON Web Tokens) — The Stateless Approach

Instead of an opaque ID looked up server-side, a JWT contains the actual claims (user ID, role, expiry) directly, encoded and cryptographically signed — the server can verify it's genuine and unmodified without needing to look anything up in a database at all.

eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjo0Miwicm9sZSI6ImFkbWluIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Three parts, separated by dots: a header (algorithm info), a payload (the actual claims), and a signature (proving it hasn't been tampered with).

The payload is encoded, not encrypted — anyone can read it
Decoding a JWT's payload is trivial (it's just base64, not encryption) — paste any JWT into a public JWT decoder and the claims are immediately readable by anyone who has the token. The signature prevents tampering, not reading. Never put a password, a secret, or genuinely sensitive personal data directly in a JWT's payload.

Comparing the Two Approaches

🗄️ Server-Side Sessions
Revocation: instant — delete the session record server-side, the user is immediately logged out everywhere.

Scaling: needs a shared session store (Redis, a database) if running multiple servers, so any server can look up any session.

Best for: traditional web apps with a single backend, anything needing instant logout/revocation (banking, admin panels).
🔑 JWT
Revocation: genuinely hard — a JWT is valid until it expires; there's no central place to "delete" it once issued, without adding back a server-side blocklist (which partly defeats the statelessness).

Scaling: any server can verify the signature independently — no shared session store needed.

Best for: APIs consumed by multiple independent services, mobile app backends, microservice architectures.
JWT's revocation problem is usually solved with short expiry + refresh tokens
Rather than trying to revoke a JWT directly, the common pattern issues a short-lived JWT (minutes) for actual API access, alongside a separate, longer-lived "refresh token" used only to obtain a new JWT. Revoking access then just means invalidating the refresh token server-side — the short-lived JWT expires naturally within minutes regardless.

Where to Store a Token — The Question That Actually Matters

Regardless of which approach is used, the token has to live somewhere in the browser — and this decision has real security consequences, directly building on Chapter 2's Secure/HttpOnly coverage.

Storage locationVulnerable to XSS?Vulnerable to CSRF?Verdict
HttpOnly cookie No — invisible to JS Yes, without SameSite/CSRF tokens Best overall, pair with SameSite=Lax/Strict
localStorage Yes — fully readable by any script No — never sent automatically Common but risky if XSS is even slightly possible
sessionStorage Yes — same exposure as localStorage No Same risk as localStorage, just shorter-lived
In-memory JS variable only Reduced — gone if the script context is destroyed No Most XSS-resistant, but lost on every page refresh
The XSS vs CSRF tradeoff is genuinely unavoidable, not a sign you're doing it wrong
An HttpOnly cookie is safe from XSS (a script literally cannot read it) but needs SameSite protection against CSRF (a malicious site tricking the browser into sending it automatically). localStorage is naturally immune to CSRF (nothing sends it automatically) but fully exposed if XSS is ever possible anywhere on the page. There is no single storage location immune to both — the real defence is preventing XSS in the first place (Chapter 6's CSP) AND using SameSite correctly (Chapter 2), not picking a "safer" storage location as a substitute for either.

A Sensible Default for Most Projects

  • Store the actual session/auth token in an HttpOnly, Secure cookie with SameSite=Lax. This is the combination covered across Chapters 2 and this one that resists the most realistic attack scenarios.
  • Use a strict CSP (Chapter 6) as the primary defence against XSS, rather than relying on storage location alone.
  • If building a separate API consumed by a mobile app or third-party service (where cookies don't naturally apply), JWTs with short expiry plus refresh tokens are the standard, well-understood pattern.

Chapter 7 Quick Reference

  • Server-side sessions — opaque ID in a cookie, real data server-side; instant revocation, needs a shared store at scale
  • JWT — self-contained, signed claims; no database lookup needed, but genuinely hard to revoke before expiry
  • JWT payload is readable by anyone — signed against tampering, not encrypted against reading
  • JWT revocation pattern: short-lived JWT + longer-lived refresh token, revoke the refresh token
  • HttpOnly cookie — safe from XSS, needs SameSite against CSRF; localStorage — safe from CSRF, fully exposed to XSS
  • No storage location is immune to both — prevent XSS via CSP, prevent CSRF via SameSite, rather than relying on storage choice alone
  • Sensible default: HttpOnly + Secure + SameSite=Lax cookie for session tokens; JWT + refresh tokens for separate/mobile APIs
  • Next chapter (capstone): debugging real browser console errors — a practical walkthrough of common storage/CORS/cookie messages