Password auth: HTTP Basic vs session cookies vs tokens
How a server remembers an authenticated user across HTTP's stateless requests — Basic resends credentials, session cookies pin server-side state, tokens carry signed claims. The tradeoffs that pick one.
On this page
The problem HTTP makes you solve
HTTP is stateless. Every request stands alone. So once a user has typed a password into a login form, the server needs a way to recognise the next request as coming from the same authenticated user — without making them retype the password each time.
Three answers have stuck around. They differ in where the identity lives between requests, and that single choice cascades into scaling, revocation, and the attack surface you sign up for.
| Scheme | Where identity lives | Server keeps state? | Revoke before expiry |
|---|---|---|---|
| HTTP Basic | In every request, base64-encoded | No | Change the password |
| Session-cookie | Server-side store, indexed by an opaque cookie | Yes | Delete the session row |
| Token (JWT) | In every request, signed payload | No (without help) | Hard — needs a denylist |
The rest of the note is what those rows actually mean in practice.
HTTP Basic — the simplest one that mostly isn’t right
The client base64-encodes username:password and sends it in Authorization: Basic <…> on every request. The server decodes, looks up the user, verifies the password hash (see 0014 Hashing passwords (not encrypting them) · System Design ), and proceeds.
GET /api/orders HTTP/1.1
Host: api.example.com
Authorization: Basic dXNlcjpodW50ZXIyCg==That dXNlcjpodW50ZXIyCg== is user:hunter2 base64-encoded. Base64 is encoding, not encryption — it’s trivially reversible. Anyone who sees the request sees the credentials.
The mode survives in three niches today: server-to-server APIs over TLS where the client is a long-lived service, one-shot CLI tooling, and basic-auth-protected internal dashboards where the whole point is “no login UI, just a browser prompt”. For end-user web apps it’s dead — every request would re-transmit the password, you can’t log out without closing the browser, and there’s no way to add MFA on top.
Session-cookie — the default for server-rendered apps
On successful login the server creates a session row (in memory, Redis, or the database) and writes its ID into a cookie:
HTTP/1.1 302 Found
Set-Cookie: sid=8c6bce3a-…; HttpOnly; Secure; SameSite=Lax; Path=/
Location: /dashboardThe browser sends the cookie on every subsequent same-origin request. The server uses the session ID to look up the row, finds the user, and proceeds. The cookie itself is opaque — knowing the ID tells you nothing about the user; you must reach the server’s session store to resolve it.
The state lives server-side. That has consequences both ways:
- Revocation is trivial. Delete the session row and the next request from that cookie is anonymous. Sign-out, password-change-everywhere, and admin-kill-this-user are all one DELETE.
- Horizontal scale needs a shared store. Two app servers can’t each keep sessions in their own memory; the request that lands on server B won’t find the session created on server A. So a Redis or a database-backed session table appears — and now session lookup is a network round-trip on every authenticated request.
Session cookies bring their own attack surface. CSRF (cross-site request forgery) is the canonical one: another site can cause the user’s browser to issue a same-origin request that automatically carries the cookie. The mitigation is SameSite=Lax (or Strict) on the cookie plus per-form CSRF tokens on state-changing endpoints. XSS is the other one — a script running on your origin can read the cookie unless it’s HttpOnly, which is why the flag is non-negotiable.
Token-based — stateless by carrying signed claims
Instead of an opaque ID pointing at server-side state, the token is the state. The server signs a small JSON payload — user ID, scopes, expiry — and gives it to the client. The client sends it back on each request, the server verifies the signature, and trusts the payload.
GET /api/orders HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsImV4cCI6MTk5OTk5OTk5OX0.s8s…The most common encoding is JWT — three base64url-encoded segments (header.payload.signature) joined by dots. The signature is computed over header.payload with either a shared secret (HS256) or an RSA/ECDSA private key (RS256/ES256). Anyone with the verification key can confirm the token is genuine; without the key, you can’t forge one.
The win is no shared session store. Any app server with the verification key can authenticate any request, instantly, without a network hop. That’s why JWTs are everywhere in mobile APIs, microservices, and anywhere a request might cross trust boundaries.
The catch is revocation. The token is valid until its exp claim. If a user’s account is compromised and you delete it, every token they minted before the deletion still verifies until expiry. The usual answers are short expirations plus refresh tokens, or a denylist of revoked token IDs (jti) — and the moment you add the denylist, you have server-side state, which is the thing tokens were supposed to avoid.
Side-by-side: what each one looks like in code
@PostMapping("/login")
ResponseEntity<Void> login(LoginReq req, HttpSession session) {
var user = users.verify(req.email(), req.password());
if (user == null) return ResponseEntity.status(401).build();
session.setAttribute("uid", user.id());
return ResponseEntity.ok().build();
}
@GetMapping("/me")
User me(HttpSession session) {
Long uid = (Long) session.getAttribute("uid");
if (uid == null) throw new UnauthorizedException();
return users.byId(uid);
}@PostMapping("/login")
ResponseEntity<TokenRes> login(LoginReq req) {
var user = users.verify(req.email(), req.password());
if (user == null) return ResponseEntity.status(401).build();
String jwt = tokens.sign(Map.of(
"sub", user.id().toString(),
"exp", Instant.now().plusSeconds(900).getEpochSecond()));
return ResponseEntity.ok(new TokenRes(jwt));
}
@GetMapping("/me")
User me(@RequestHeader("Authorization") String header) {
var claims = tokens.verify(header.substring(7));
return users.byId(Long.parseLong(claims.get("sub")));
}The session version stores uid in a server-side store (Spring’s HttpSession defaults to in-memory; swap in Spring Session + Redis for horizontal scale). The JWT version stores nothing — every authenticated request just verifies the signature.
What each subsequent request actually costs
Login happens once. The rest of the user’s session is hundreds of authenticated requests, and the cost of each of those requests is what separates these schemes in production:
That single extra hop in the top sequence is the whole point: session cookies trade per-request latency and a piece of shared infrastructure for trivial revocation. JWTs trade trivial revocation for zero-state verification. Neither is “faster” or “more secure” in the abstract — they pay different costs on different axes.
The axes that decide
| Axis | Session cookie | JWT |
|---|---|---|
| Server-side state | Required (in-memory, Redis, or DB) | None for verify; denylist appears once you need revocation |
| Horizontal scale | Needs a shared session store | Any node with the verification key authenticates instantly |
| Revocation | Delete the row — immediate | Wait for exp, or maintain a denylist |
| Cross-origin / mobile | Cookies are clumsy across origins; CSRF surface | Bearer header is straightforward from any client |
| Payload size per request | ~40 bytes (session ID) | Hundreds of bytes to ~1KB depending on claims |
| XSS impact | HttpOnly cookie is unreachable from page JS | Token usually lives in JS-readable storage — XSS = token theft |
| Default for | Server-rendered web apps, classic monoliths | SPAs talking to APIs, mobile clients, service-to-service |
How to pick
- Server-rendered web app on one or a few servers → session cookies. The CSRF mitigations are well-understood, revocation is free, and you skip an entire class of token-management code.
- SPA or mobile client talking to a JSON API → tokens, with short expirations and a refresh-token path. Cookies still work here but cross-origin and mobile-storage handling is more painful than it sounds.
- Service-to-service inside a trust boundary → either signed tokens (often with mTLS underneath) or HTTP Basic over a private network. No human, no UI, no session.
- Public APIs given to third-party developers → tokens, with proper OAuth 2.0 issuance — see 0018 OAuth 2.0 authorization code flow, step by step · System Design . Don’t roll your own.
What this note is not about
Passwords-stored-correctly is its own concern — even the most carefully designed session or token system collapses if the password hash on disk is SHA256(password). That’s the subject of 0014 Hashing passwords (not encrypting them) · System Design . And removing the password from the login step entirely is a different shape again: 0017 Passwordless authentication: OTP, SSO, and OAuth/OIDC · System Design surveys OTP, SSO, and OAuth/OIDC as alternatives to passwords altogether.