Cookies, Sessions, and JWTs
HTTP is stateless. Every request is independent — the server has no built-in memory of who you are or what you did one request ago. That is a feature, not a bug: statelessness makes HTTP simple, cacheable, and scalable. But the moment you need login, shopping carts, or personalization, you need a mechanism to track state across requests. Cookies, server-side sessions, and JWTs are the three main approaches, each with distinct trade-offs in security, scalability, and complexity.
Session vs JWT Authentication
Session-Based Auth
Login
POST /login
Redis
stores session state
Opaque Session ID
sent in cookie
Request Verified
Redis lookup on every request
Logout
Redis row deleted
Next Request
Redis lookup fails — 401
Immediate invalidation
JWT-Based Auth
Login
POST /login
Server Signs Token
HMAC-SHA256 signature
Client Holds Token
stored in localStorage
Signature Check
no server state needed
Redis
not used
Logout
client deletes token
Token Replayed
signature still valid — 200 OK
TTL: expired
Blocklist Check
token revoked — 401
Redis
dependency re-added
Back to server state
Decoded JWT Payload (plaintext)
{
"sub": "user123",
"exp": 1234567890,
"iat": 1234567800
}Cookies
A cookie is a small key-value pair that the server sends to the browser via a Set-Cookie response header. The browser stores it and automatically attaches it to every subsequent request to that domain. That is the entire mechanism — no magic, just an automatic header.
Set-Cookie: session_id=abc123; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=3600
Cookie Flags That Matter
HttpOnly — The browser will not expose this cookie to JavaScript. document.cookie cannot read it. This is your primary defense against XSS-based token theft. If an attacker injects a script into your page, they cannot exfiltrate an HttpOnly cookie. Always set this for authentication cookies.
Secure — The cookie is only sent over HTTPS connections. Without this flag, the cookie travels in plaintext over HTTP, and anyone on the network (coffee shop WiFi, corporate proxy) can read it.
SameSite — Controls whether the cookie is sent on cross-site requests. This is your primary CSRF control:
Strict: Cookie is never sent on cross-site requests. Maximum protection, but breaks legitimate flows like clicking a link from an email to your logged-in app.Lax: Cookie is sent on top-level navigations (GET requests from external links) but not on cross-site POST requests or embedded resources. This is the practical default.None: Cookie is sent on all cross-site requests. RequiresSecureflag. Only use this if you genuinely need cross-origin cookie sharing (e.g., embedded widgets).
Domain and Path — Scope which URLs receive the cookie. A cookie set with Domain=.example.com is sent to all subdomains. Path=/api restricts it to requests under /api.
Max-Age / Expires — How long the cookie lives. Session cookies (no expiry set) are deleted when the browser closes. Persistent cookies survive browser restarts until their expiry.
Server-Side Sessions
A server-side session stores user state on the server — typically in Redis, Memcached, or a database. The cookie holds only an opaque session ID (a random string with no meaning). When a request arrives, the server looks up the session ID in its store and retrieves the associated user data.
Browser sends: Cookie: session_id=a8f2e9c1...
Server does: redis.get("session:a8f2e9c1") → { userId: 42, role: "admin", cart: [...] }
Advantages
Revocable instantly. Need to log out a user, invalidate a compromised session, or force re-authentication? Delete the session from your store. The next request with that session ID gets a "not found" and the user is logged out. No waiting for expiry.
Small cookie size. The cookie contains only the session ID. All the actual data lives server-side, so you are not sending a payload back and forth on every request.
Server controls the data. The client never sees or modifies the session contents. There is no risk of the user tampering with their role or permissions.
Trade-offs
Shared session store required. If you run multiple application servers behind a load balancer, they all need access to the same session store. Sticky sessions (routing the same user to the same server) are an alternative, but they complicate scaling and failover.
State on the server. You are maintaining per-user state, which means your session store needs to scale with your user count. For most applications this is trivial — Redis handles millions of sessions — but it is a dependency you need to operate and monitor.
JWTs (JSON Web Tokens)
A JWT is a signed, self-contained token. It carries user data (claims) directly in the token itself, so the server does not need to look anything up. The structure is three base64url-encoded segments separated by dots: header.payload.signature.
eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjQyLCJyb2xlIjoiYWRtaW4iLCJleHAiOjE3MDk5MzIwMDB9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Decode the payload (it is just base64, not encrypted):
{
"userId": 42,
"role": "admin",
"exp": 1709932000
}
The payload is not encrypted. Anyone with the token can read its contents. The signature only guarantees the data has not been tampered with — it does not hide it. Never put secrets, passwords, or sensitive PII in a JWT payload.
The Revocation Problem
This is the fundamental JWT trade-off. Once issued, a JWT is valid until it expires. You cannot invalidate it early without maintaining a server-side blocklist of revoked tokens — which reintroduces the very state you were trying to avoid by going stateless.
User changes their password and you want to invalidate all existing tokens? You need a blocklist. User's account is compromised and you need to force logout? Blocklist. Admin revokes a user's access? Blocklist, or wait for the token to expire.
Known JWT Attacks
alg: none attack. Early JWT libraries accepted tokens with the algorithm set to none, meaning no signature verification. An attacker could forge any token with any claims. Modern libraries reject this by default, but always explicitly specify allowed algorithms.
Algorithm confusion. If a server is configured for RSA (asymmetric) verification but accepts HMAC (symmetric), an attacker can sign a forged token using the server's public RSA key as the HMAC secret. The server verifies it successfully because it uses the same key for HMAC verification. Always enforce the expected algorithm on the server side.
The Practical Compromise: Access + Refresh Tokens
Most production JWT implementations use a two-token pattern:
- Access token: Short-lived (5-15 minutes). Sent with every API request. If stolen, the blast radius is limited by the short expiry.
- Refresh token: Long-lived (days or weeks). Stored securely, used only to request new access tokens. Revocable — stored in a database so it can be deleted.
This gives you the stateless benefits of JWTs for routine API calls while keeping revocation possible through the refresh token.
Where to Store Tokens
This decision matters more than most teams realize.
localStorage — Accessible to any JavaScript running on the page. If your application has an XSS vulnerability, an attacker's injected script can read the token and exfiltrate it to their server. Tokens in localStorage persist across browser restarts.
HttpOnly cookie — Not accessible to JavaScript at all. An XSS vulnerability cannot read the token. The browser sends it automatically, which means you need CSRF protection (SameSite flag handles this in modern browsers). This is the safer default.
In-memory (JavaScript variable) — Gone on page refresh. Good for very short-lived tokens in single-page apps, but requires a refresh mechanism.
The recommendation: store authentication tokens in HttpOnly, Secure, SameSite cookies. Use localStorage for non-sensitive preferences, not for anything that grants access.
When to Use What
Server-side sessions are a fine default for most web applications. They are simple, revocable, well-understood, and battle-tested. If you are building a monolith or a small service with a handful of servers, sessions in Redis are straightforward.
JWTs make sense when you have genuinely stateless, distributed services that need to verify identity without calling back to a central store — microservices architectures, third-party API authentication, or situations where the token consumer is different from the token issuer. Most teams reach for JWTs because they sound modern, not because they need the trade-offs.
Cookies are not an alternative to sessions or JWTs — they are the transport mechanism. Both session IDs and JWTs can be (and usually should be) stored in cookies.
Key Takeaways
- HTTP is stateless; cookies are the browser's mechanism for sending state with every request to a domain
- HttpOnly, Secure, and SameSite are the three cookie flags that directly prevent XSS token theft, network sniffing, and CSRF respectively
- Server-side sessions store state in Redis/DB and give you instant revocation — they require a shared store when scaling horizontally
- JWTs are signed and self-contained but not encrypted — anyone can read the payload, and you cannot revoke them without reintroducing server-side state
- The access token (short, stateless) + refresh token (long, revocable) pattern is the practical compromise for JWT-based systems
- Store authentication tokens in HttpOnly cookies, not localStorage — XSS cannot read what JavaScript cannot access
Next up: SQL injection — the most infamous injection attack and how parameterized queries eliminate it.
Enjoyed this breakdown?
Get new lessons in your inbox.