Authentication, sessions and JWT
We'll cover the following
AuthN vs AuthZ
- Authentication (authN) = who are you? Identity: password, OTP, Google login.
- Authorization (authZ) = what may you do? Permissions: admin vs viewer. "Can this user delete that post?"
Login is authN. Every action after login needs an authZ check.
The problem: HTTP forgets you between requests
After login, how does the server know request #2 is still you? Two answers: a session (coat-check ticket) or a token (ID badge with a hologram).
| Session (cookie) | Token (JWT) | |
|---|---|---|
| What the client holds | A random session ID | A signed token with claims (user_id, role, exp) |
| Each request | Server looks the ID up in a store (Redis or DB, ~1 ms) | Server only verifies the signature; no lookup |
| State | Server-side (stateful) | Client-side (stateless) |
| Logout / revoke | Easy: delete the entry | Hard: valid until it expires |
| Scaling | Needs a shared session store | Any gateway or service can verify it alone |
| Pick it for | Simple web apps where instant logout matters | APIs, microservices, gateways that verify without a shared store |
Sequence between browser, server and session store. Login: the browser posts credentials, the server verifies the password, stores session abc123 mapping to user 42 in the session store and returns a Set-Cookie. Later requests: the browser sends the cookie, the server looks abc123 up in the store and returns the data. Logout deletes the record, so the next request with abc123 is rejected.
- Login
- Browser → App server
POST /loginwith email and password - App serverVerify the password hash
- App server → Session store
SET session:abc123 = user 42(with TTL) - Session store → App serverOK
- App server → Browser
200+Set-Cookie: session_id=abc123 - Every later request
- Browser → App server
GET /orders+Cookie: session_id=abc123 - App server → Session store
GET session:abc123 - Session store → App server
user 42 - App server → Browser
200your orders - Logout
- Browser → App server
POST /logout - App server → Session store
DEL session:abc123(next lookup fails, so401)
- Cookie
- The browser stores
session_idand sends it automatically with every request. UseHttpOnlyandSecure. - Random session ID
- An unguessable value like
abc123. It carries no data; it is only a key. - Session store (Redis)
- Maps ID to user. Shared by all servers, which is the extra dependency on every request (~1 ms).
A JWT is not only an OAuth output. OAuth can issue tokens, but you can also issue your own JWTs from a plain login. And the stateless property is why 50 gateway instances can each verify a token by themselves.
How a JWT works
header.payload.signature
payload = { "user": 42, "role": "admin", "exp": 1730000000, "jti": "a1b2" }- Signed, not encrypted. The payload is only base64-encoded, so anyone holding the token can read it. Never put secrets in it.
- Expiry lives inside the token.
expis a timestamp in the signed payload. On each request the server checks (1) the signature is valid and (2)expis earlier than the current time. No lookup. - Editing breaks the signature. The signature covers header and payload together. A hacker who changes
expto next year makes the payload mismatch the signature, so the server rejects it with 401. They would need the signing key to forge one. - It works for both browser or mobile users (
Authorization: Bearer <token>header) and server-to-server calls (a service gets a token and presents it the same way). Only the issuer and audience differ.
Signing: HS256 vs RS256
| How | Note | |
|---|---|---|
| HS256 (shared secret) | One secret both signs and verifies | Simple, but every verifier must hold the secret, so any service could mint tokens |
| RS256 (key pair) | Auth server signs with the private key; gateways and services verify with the public key | Services can verify but never create tokens. The better fit for many services. |
Verification is a cheap CPU calculation (microseconds) with no network call, which is why it scales.
Three stages. Generated: the auth server builds header, payload and signature after login, signing with the private key (RS256) or a shared secret (HS256). Shared: the client receives the token and sends it on every request as a Bearer header or an HttpOnly cookie, and holds a refresh token for new access tokens. Verified: the gateway or service checks the signature with the public key from the JWKS endpoint (RS256) or the shared secret (HS256), checks that exp has not passed, that the jti is not in the Redis denylist, and that iss and aud match, without calling the auth server.
1 Generated
Auth server, right after a successful loginHeader{ "alg": "RS256", "typ": "JWT" }which algorithm signed itPayloadclaims:sub,role,exp,jti,iss,audreadable, not editableSignaturesign(base64(header) + "." + base64(payload))needs the key- The token is
header.payload.signature, each part base64-encoded. sub= user id,exp= expiry timestamp,jti= unique token id (for the denylist).
Signs withthe private key (RS256) or the shared secret (HS256). Only the auth server holds the signing key.- The token is
2 Shared
Returned to the client, which carries it- The client sends it on every request:
Authorization: Bearer <token>. - Or the server sets it in an HttpOnly cookie, so JavaScript cannot read it (safer against XSS).
- Alongside it the client holds a refresh token (long-lived, tracked server-side) to get a new access token when this one expires (~15 min).
Key needednone. The client just carries the token; it cannot change the payload without breaking the signature.- The client sends it on every request:
3 Verified
Gateway or service, on every request- Signature valid? Recompute and compare. Any edit to header or payload fails here.
expnot in the past? Compare with the current time.jtinot in the denylist? One Redis lookup for revoked tokens.issandaudmatch? Issued by our auth server, meant for this API.- No call to the auth server per request. That is what makes it stateless.
Verifies withthe public key (RS256), fetched once from the auth server's JWKS endpoint and cached, or the shared secret (HS256).
The payload is only base64, so anyone can read it, nobody can edit it. With RS256 the private key signs and the public key can only verify, so a service that verifies tokens can never mint one.
Short-lived access token plus refresh token
- Access token lives about 15 minutes. Because it cannot be recalled, keep it short.
- Refresh token lives days or weeks and is tracked server-side, so it can be revoked.
- When the access token expires, the app quietly swaps the refresh token for a new access token. The user sees nothing.
- If the refresh token is also expired or revoked, the user logs in again.
- "Stay logged in for 30 days" = a 30-day refresh token.
Storing passwords: hash, never encrypt
- Encryption can be reversed with the key; a hash is one-way, so a leaked DB does not reveal passwords.
- Store
argon2idorbcryptof the password with a unique random salt per user. The salt makes identical passwords hash differently and defeats rainbow tables. - These algorithms are deliberately slow, so guessing millions of passwords is expensive. Fast hashes (MD5, plain SHA-256) are the wrong tool.
- At login: hash what was typed with the stored salt and compare.
Attacks and defenses
| Attack | Defense |
|---|---|
Tamper with the payload (e.g. extend exp) | Signature check fails. Protect the signing key; it is the crown jewel. |
alg: none trick: edit the header to say "no signature needed" | The server enforces its own expected algorithm and never trusts the header's choice |
| Steal a valid token (XSS, leaked logs) | Short expiry, HTTPS only, store in an HttpOnly cookie so JavaScript cannot read it. A cookie is sent automatically, so add SameSite=Lax/Strict and Secure against CSRF |
| Brute-force a weak HS256 secret | Long random secrets, or use RS256 |
Revoking a stolen token: "log out everywhere"
A stolen valid token cannot be edited, but it works until it expires, and the server does no lookup. Add a denylist to the verify step:
- Every token carries a unique ID,
jti. - On logout-everywhere or compromise, put the
jti(or the user's "tokens issued before time T") into a small Redis set. - Verify becomes: signature OK, not expired, and
jtinot on the denylist. - The list stays tiny because entries only need to live as long as the token's short lifetime.
Check yourselfA user's JWT is stolen and they click "log out everywhere". The server only verifies signatures. How do you cut off that token?
Add a denylist of revoked jti values (a small Redis set with TTL equal to the token lifetime) and check it in the verify step. Combine with a short access-token lifetime and revoking the refresh token, so no new access tokens can be minted.
Summary: say it in the interview
- "Short-lived JWT access tokens plus revocable refresh tokens, verified at the gateway. A small denylist covers immediate revocation."