Q57 — How does JWT work — and what are its real security pitfalls?
Why sessions exist — and the problem they create at scale
When a user logs in, the server must remember them for subsequent requests. The original web model: create a session object on the server, store it (in memory, Redis, or a database), and give the browser a session ID cookie. Every subsequent request sends the cookie; the server looks up the session ID, retrieves the session data, and knows who the user is.
This works but creates a coupling: the server must have access to the session store on every request. In a monolith with a single server, session memory works fine. In a distributed system with 50 application servers behind a load balancer, a request might hit a different server each time — and each server would need to look up the session from a shared store (Redis, a session database). The session store becomes a shared dependency: another system to operate, a potential bottleneck, a single point of failure.
The session store also has a consistency problem: if a user logs out and you delete their session, you need to update the shared store and have every server honor that immediately. If you're caching sessions locally, you need cache invalidation.
JWTs are an attempt to make authentication stateless — to encode the session data into a token that the client carries, eliminating the need for a shared server-side session store.
What is a JWT?
A JWT (JSON Web Token) is a self-contained token that encodes user identity and claims, signed by the server, carried by the client on every request.
It has three base64url-encoded parts separated by dots:
Header: specifies the token type and signing algorithm.
{ "alg": "HS256", "typ": "JWT" }
Payload (claims): the actual data — who the user is, what they're allowed to do, when the token expires.
{
"sub": "user_42",
"email": "alice@example.com",
"roles": ["customer"],
"iat": 1710000000,
"exp": 1710086400
}
Signature: the server's cryptographic proof that this token was issued by it and has not been tampered with.
HMACSHA256(base64url(header) + "." + base64url(payload), secret_key)
The full JWT looks like:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzQyIiwiZW1haWwiOiJhbGljZUBleGFtcGxlLmNvbSIsImV4cCI6MTcxMDA4NjQwMH0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
It's not encrypted (anyone can decode the base64 and read the payload) — it's signed. The server verifies the signature on every request. If the signature is valid, the token was issued by this server and hasn't been modified. No database lookup required — the signature itself is the proof.
The stateless authentication flow
- User sends username + password to
/login - Server verifies credentials (Q56 — bcrypt comparison)
- Server generates a JWT with user ID, roles, expiry → signs it with the server's secret key → sends it to the client
- Client stores the JWT (localStorage or an httpOnly cookie) and sends it in the
Authorization: Bearer <token>header on every subsequent request - Server receives the token → verifies signature (no DB lookup) → extracts user ID and roles from payload → processes request
The server can verify tokens without any shared state. Any server with the same signing secret can verify any token — no session store, no shared cache, no inter-service calls for auth on every request.
For microservices: the API gateway can verify the JWT once and pass the decoded claims to downstream services via internal headers. Downstream services trust those headers without needing to call the auth service themselves.
Pitfall 1 — The "alg: none" attack
JWT tokens include the alg field in the header. Early JWT libraries had a catastrophic vulnerability: if a client sent a token with "alg": "none", some libraries would skip signature verification entirely, accepting the token as valid regardless of the payload.
An attacker could forge a JWT with any user ID and roles, set alg: none, and gain arbitrary access.
The fix: never trust the alg field from the token itself. The server should enforce which algorithms are acceptable (HS256 or RS256, never "none") as a server-side configuration, ignoring whatever the token claims.
Pitfall 2 — HS256 vs RS256 — symmetric vs asymmetric signing
HS256 (HMAC-SHA256): uses the same secret key to sign and verify. Whoever verifies the token must know the secret key. In a microservices environment, this means the secret must be distributed to every service that needs to verify tokens. If any service is compromised, the attacker can forge tokens for any user.
RS256 (RSA-SHA256): uses a private key to sign (held only by the auth service) and a public key to verify (distributable to any service). A compromised downstream service can verify tokens but cannot forge them. This is the right choice for multi-service architectures: the auth service keeps the private key; all services get the public key to verify.
The rule: use RS256 (or ES256 — ECDSA) for any multi-service system. Only use HS256 if a single service both issues and verifies all tokens.
Pitfall 3 — You cannot revoke JWTs before expiry
This is the central limitation of JWTs. Once a token is issued and signed, it is valid until exp (expiry). There is no server-side state to invalidate — that was the point.
What happens when:
- A user logs out? The client discards the token, but the token is still valid until expiry. If someone captured it, they can still use it.
- A user's account is compromised and an admin disables it? The token is still valid until expiry.
- A user's roles change (demoted from admin)? The token still claims admin roles until it expires.
Solutions, all involving trade-offs:
Short expiry + refresh tokens: issue access tokens with a short expiry (15 minutes) and a separate, long-lived refresh token stored server-side (in a database). On expiry, the client uses the refresh token to get a new access token. Compromised access tokens expire quickly. Refresh tokens can be revoked in the database. The refresh token is the server-side stateful component — you haven't eliminated state, you've reduced how often it's consulted.
Token blocklist: maintain a list of revoked token IDs (the jti claim, a unique JWT identifier) in Redis. On every request, after signature verification, check if the token's jti is in the blocklist. Adds one Redis lookup per request — partially re-introduces the stateful session model, but keeps the lookups cheap (Redis is fast) and the blocklist bounded by expiry (entries auto-expire when the token would have expired anyway).
Very short expiry: 5-minute tokens effectively limit the damage of a stolen token without requiring a blocklist. But it means 5-minute sessions for users unless you implement refresh tokens.
Pitfall 4 — Sensitive data in the payload
The JWT payload is base64-encoded, not encrypted. Anyone who intercepts the token (or the user themselves) can decode and read the payload. Never put sensitive information in a JWT: passwords, session data, PII beyond what's necessary, internal system details.
Use the minimum claims needed: user ID, roles, expiry. Fetch additional data from the database when needed using the user ID from the token.
Pitfall 5 — Storage location — localStorage vs httpOnly cookie
If a JWT is stored in localStorage, it's accessible to JavaScript running on the page — including injected malicious scripts (XSS attacks). An XSS vulnerability on any page can steal all JWTs and send them to an attacker's server.
If a JWT is stored in an httpOnly cookie, JavaScript cannot access it. The browser automatically sends it on requests to the same domain. The remaining attack vector is CSRF (cross-site request forgery) — a malicious site tricking the browser into making requests with the cookie. Mitigate CSRF with SameSite cookie attribute (Strict or Lax) and CSRF tokens.
The security consensus: store JWTs in httpOnly, Secure, SameSite=Strict cookies. Avoid localStorage for auth tokens. The CSRF risk from cookies is smaller than the XSS risk from localStorage for most applications.
The mental model
JWT = self-contained signed token
Header (alg, typ) + Payload (claims) + Signature
Base64url-encoded, NOT encrypted (payload is readable)
Signature = HMAC or RSA proof of origin and integrity
Authentication flow:
Login → verify password → issue JWT with {user_id, roles, exp}
Client sends JWT in Authorization header
Server verifies signature (no DB lookup) → extracts claims → authorized
Why stateless is valuable:
No shared session store → any server can verify any token
API gateway verifies once → passes claims to downstream services
Scales horizontally without coordination
Pitfalls:
1. "alg:none" attack → enforce alg server-side, never trust token's alg field
2. HS256 (symmetric) → use RS256 (asymmetric) for multi-service architectures
Private key: auth service only
Public key: distributed to all verifying services
3. No revocation → token valid until exp regardless of logout/disable
Fix A: short expiry (15min) + server-side refresh token
Fix B: token blocklist in Redis (jti blocklist, auto-expires)
4. Payload not encrypted → never put sensitive data in JWT
5. localStorage → XSS steals tokens
Use httpOnly, Secure, SameSite=Strict cookie instead
The fundamental trade-off:
Stateless = fast, horizontally scalable, no session store
But: no revocation without re-introducing server-side state
Long-lived JWTs without revocation = risky
Practical standard: short access tokens (15min) + revocable refresh tokens
JWTs solve the session-store scaling problem elegantly but introduce a new problem: you can't take back a token you already issued. Every JWT system in production must answer "what happens when we need to revoke this?" before it can be considered secure.