Authentication, sessions and JWT

Lesson 6.1 · 12 min read · by Vinay C

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 holdsA random session IDA signed token with claims (user_id, role, exp)
Each requestServer looks the ID up in a store (Redis or DB, ~1 ms)Server only verifies the signature; no lookup
StateServer-side (stateful)Client-side (stateless)
Logout / revokeEasy: delete the entryHard: valid until it expires
ScalingNeeds a shared session storeAny gateway or service can verify it alone
Pick it forSimple web apps where instant logout mattersAPIs, microservices, gateways that verify without a shared store
Session flow: the server looks the ticket up on every request

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.

Browserkeeps the cookie
App serverany instance
Session storeRedis or DB
  1. Login
  2. Browser → App serverPOST /login with email and password
  3. App serverVerify the password hash
  4. App server → Session storeSET session:abc123 = user 42 (with TTL)
  5. Session store → App serverOK
  6. App server → Browser200 + Set-Cookie: session_id=abc123
  7. Every later request
  8. Browser → App serverGET /orders + Cookie: session_id=abc123
  9. App server → Session storeGET session:abc123
  10. Session store → App serveruser 42
  11. App server → Browser200 your orders
  12. Logout
  13. Browser → App serverPOST /logout
  14. App server → Session storeDEL session:abc123 (next lookup fails, so 401)
Cookie
The browser stores session_id and sends it automatically with every request. Use HttpOnly and Secure.
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. exp is a timestamp in the signed payload. On each request the server checks (1) the signature is valid and (2) exp is earlier than the current time. No lookup.
  • Editing breaks the signature. The signature covers header and payload together. A hacker who changes exp to 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

HowNote
HS256 (shared secret)One secret both signs and verifiesSimple, 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 keyServices 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.

JWT lifecycle: generated, shared, verified

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. 1

    Generated

    Auth server, right after a successful login
    Header{ "alg": "RS256", "typ": "JWT" }which algorithm signed it
    Payloadclaims: sub, role, exp, jti, iss, audreadable, not editable
    Signaturesign(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.
  2. 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.
  3. 3

    Verified

    Gateway or service, on every request
    • Signature valid? Recompute and compare. Any edit to header or payload fails here.
    • exp not in the past? Compare with the current time.
    • jti not in the denylist? One Redis lookup for revoked tokens.
    • iss and aud match? 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 argon2id or bcrypt of 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

AttackDefense
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 secretLong 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:

  1. Every token carries a unique ID, jti.
  2. On logout-everywhere or compromise, put the jti (or the user's "tokens issued before time T") into a small Redis set.
  3. Verify becomes: signature OK, not expired, and jti not on the denylist.
  4. 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."