AuthN vs AuthZ: Sessions & JWT
Two Questions, Two Codes
Last lesson, the gateway's auth chamber did something we hand-waved: it took a token, "verified the JWT," and out popped an identity — which it then trusted, without calling any database, to decide whether to let the request in. Two questions were buried in that sentence, and cracking them open is this whole lesson. First: who are you? Second, only after that's answered: and what are you allowed to do?
Those are the two halves of access control, and they are genuinely different — different enough that HTTP gives them different failure codes, the ones lesson one introduced without their real names:
- Authentication (authN) — who are you? Proving identity: a password, a passkey, a token. Fail and you get 401 — badly named "Unauthorized," but it actually means un-authentiCATED: I don't know who you are.
- Authorization (authZ) — what may you do? Given a known identity, are you permitted to do this thing? Fail and you get 403 Forbidden: I know exactly who you are, and the answer is still no.
You can be flawlessly authenticated and completely forbidden — a logged-in user hitting the admin panel is a 403, not a 401. AuthN always runs first (you can't decide permissions for a stranger), and it's the harder engineering problem, because of one stubborn fact from the networking chapter: HTTP forgets. Every request arrives with amnesia — no memory that you logged in thirty seconds ago. So after that single login, how does request #2 prove it's still you without re-sending your password on every click?
There are exactly two answers. And — this is the quiet punchline of the whole section — they are §3's stateful versus stateless debate wearing an authentication costume. Either the server remembers (a session), or the token carries its own proof (a JWT). Every difference that follows — scaling, revocation, the famous attacks — falls out of that one fork.

Answer One: Sessions — The Server Remembers
The older answer is the intuitive one: let the server keep a memory.
You log in. The server creates a session record somewhere on its side — a row in Redis or a database — holding who you are and maybe when you logged in, keyed by a big random session ID. It hands that ID back to your browser in a cookie (flagged HttpOnly so JavaScript can't read it, Secure so it only travels over TLS, SameSite to blunt CSRF). From then on, every request automatically carries the cookie; the server takes the ID, looks it up in its store, and — there you are.
The crucial property: the cookie is opaque. It's just a random pointer; it holds no information. All the truth — your identity, your roles — lives server-side, where you can't touch it. And that server-side truth buys the single best feature sessions have: instant revocation. Want to log someone out? Delete the row. Their very next request looks up an ID that no longer exists, and they're bounced to login immediately. "Log out of all devices," ban-a-user-right-now, force-logout-after-a-password-change — all trivial, because there's a central switch you can flip. This is exactly why banks, admin dashboards, and anything where control matters more than scale still run sessions.
The cost is the mirror image, and you already know its name: this is stateful (§3). Every single request pays for a lookup in a shared store — and that store must be shared across all your servers, or a user's session on server A is invisible to server B (which is precisely the sticky-session mess from the load-balancing chapter — the same thread, tied off here). The session store becomes a dependency your whole fleet leans on: one more thing to scale, one more thing that can be slow, one more thing that can go down. Sessions trade a little latency and a shared-state dependency for total control. Now meet the answer that makes the opposite trade.
Answer Two: JWT — The Token Carries the Proof
The stateless answer flips the arrangement: don't make the server remember anything — make the token prove itself.
You log in. Instead of storing a session, the server builds a JSON Web Token — a self-contained, signed claim of who you are — and hands you the whole thing. Every later request carries that token; the server reads your identity straight out of it and checks it's genuine, with no lookup, no store, no database call at all. A JWT is three base64 chunks joined by dots — header.payload.signature — and each part has a job:
- Header —
{"alg": "HS256", "typ": "JWT"}— which signing algorithm was used. - Payload (the claims) —
{"sub": "user_42", "role": "admin", "exp": 1720000000, "iss": "auth.app"}— who you are plus metadata. The standard claims are worth knowing:sub(subject — the user),exp(expiry),iss(issuer),aud(audience),iat(issued-at),jti(a unique token id). - Signature —
HMAC(secret, header + "." + payload)— the seal that makes the whole thing trustworthy.
Now the fact that trips up more engineers than any other in this section, so read it twice: base64 is not encryption. The payload is readable by anyone — copy any JWT, paste it into jwt.io or pipe it through base64 -d, and every claim is right there in plaintext, no key required. Never put a secret in a JWT. The signature does not hide the data. It does exactly one thing: it proves the data wasn't tampered with.
And that is the piece that finally explains the gateway's magic trick. Change one byte of the payload — bump "role": "user" to "role": "admin" — and the signature the server recomputes over those bytes no longer matches the one attached. Verification fails. So the server can trust a token it has never seen before, from a user it isn't tracking, without calling anyone — because only the holder of the secret could have produced a signature that matches these exact bytes. That's stateless authentication in one sentence, and it's the whole appeal: no session store, and any server can verify a token entirely on its own. Add a server, remove a server, spread across regions — no shared state, no session affinity, no lookup on the hot path. It's the §3 stateless dream, realized for auth.

See It: Crack the Token
Two claims from the last two sections deserve to be proven with your own hands — that the payload is readable by anyone, and that the signature catches any tampering — so here's a live JWT inspector, and you're going to attack it.
Start by editing the payload as plain JSON: change "role": "user" to "role": "admin", or swap the sub. Watch two things happen at once. The base64 string updates instantly — there was no lock to pick, it's just encoding — and the verify light snaps from green to red, because the signature no longer matches the bytes you changed. That red light is the gateway's trust mechanism: the server would reject your forged token without ever calling a database, purely because the math doesn't add up. Now type the secret in and re-sign — green again. The entire scheme, you'll notice, rests on that one string staying secret.
Then run the classic attacks the inspector arms for you. Flip on alg: none and watch a naive verifier wave the forged token through while a hardened one (which pins its own algorithm) rejects it — a real vulnerability that has bitten real libraries. Hit reveal to base64-decode the payload in place and read every claim, no key needed. And set an expiry, then watch it tick down to expired — a token you now cannot un-expire, no matter what. That last one is the crack in the whole design, and it's the next section. (Flip the toggle to session mode and hit revoke to feel the contrast: one deleted row, logged out instantly — the exact power the JWT just gave away.)

The Catch: You Can't Un-Sign a Token
Every superpower has its exact inverse, and the JWT's is brutal in its symmetry. Sessions revoke instantly because the server holds the truth. JWTs skip the lookup because the server holds nothing — which means when you need to take a token back, there is nothing to delete.
A JWT is valid from the moment it's signed until its exp timestamp, full stop. The server isn't tracking it, so "log this user out everywhere," "ban this account now," "that laptop was stolen — kill its access" all run into the same wall: the token is a signed, self-contained fact loose in the world, and until it expires, every server will keep honoring it. A stolen JWT is a valid JWT until the clock runs out.
The industry's fixes all quietly smuggle some state back in — which is the tell that "stateless auth" was always a spectrum, not a free lunch:
- Short-lived access tokens plus a refresh token — the dominant pattern by far. The access token lives 5–15 minutes (so a stolen one is only briefly dangerous), and a long-lived refresh token — stored server-side, like a session — is used to mint fresh access tokens. Revoke access by deleting the refresh token; the stolen access token dies within minutes on its own. Notice what that refresh token is: a session in a trench coat.
- A deny-list of revoked
jtis in Redis (with TTL set to the token's own expiry, so it self-cleans), checked on every request — which is, of course, a lookup: partial statefulness, right back on the hot path. - Refresh-token rotation and families — each refresh issues a new refresh token and invalidates the old one; if a rotated-out token ever reappears, someone stole it, so you revoke the whole family. This is how you detect theft rather than just wait it out.
So the honest description of production JWT auth is not "stateless." It's "stateless for fifteen minutes, anchored to a stateful refresh token." You didn't escape state — you shrank the window in which it doesn't matter.
The Attacks Every Token Faces
Because the JWT's trust rests entirely on cryptography the client can see, a handful of attacks recur constantly — and knowing them is the difference between using JWTs and using them safely:
alg: none. The header names the algorithm, and shockingly, early libraries trusted it — so an attacker sets"alg": "none", leaves the signature empty, and naive verification skips the check and returns valid. Any payload the attacker wants, accepted. This bit real, widely-used libraries. The fix is a one-liner and a mindset: the server pins the algorithm it will accept and never readsalgfrom the token.- Algorithm confusion (HS256 ↔ RS256). A server expecting RS256 verifies with a public key. An attacker signs a token with HS256 using that public key as the HMAC secret — and a sloppy library, seeing HS256, happily verifies it with the public key it already has. The public key is… public. Same fix: pin the exact algorithm, don't let the token choose.
- Secrets in the payload. Someone forgets base64 isn't encryption and stuffs a PII field, an internal ID, or an API key into the claims. Now anyone who intercepts the token — or just decodes their own — can read it.
- A weak or leaked secret. The entire scheme is one HMAC secret away from collapse. If it's guessable, or committed to a git repo (it happens weekly), anyone can forge any token. Long random secrets, in a real secret manager, rotated.
- No or endless expiry. No
exp, or a year-long one, turns the revocation problem from "hard" into "permanent" — a stolen token that never dies.
Every one of these traces back to the same root: a JWT is trusted math, not a trusted lookup — so the math, and the secret behind it, have to be airtight.
The Trade-offs: Sessions vs JWT (and the Honest Answer: Both)
Line them up and the choice is the §3 fork, sharpened by everything above:
| Session | JWT | |
|---|---|---|
| Where identity lives | server store (cookie is just a pointer) | inside the signed token |
| Per-request cost | a store lookup (I/O) | a signature verify (CPU, no I/O) |
| Revocation | instant — delete the row | hard — wait for exp, deny-list, or short-lived + refresh |
| Statefulness (§3) | stateful — the store must be shared & scaled | stateless… for ~15 min, then a stateful refresh anchor |
| Scaling | the store is the shared dependency | any server verifies alone — no affinity |
| Shines for | web apps, dashboards, "log out everywhere," bank-grade control | APIs, mobile, cross-domain, service-to-service, microservices |
The real-world answer, as usual, refuses to pick a team. Production systems run both, aimed at different clients. The classic 2026 shape: the web app uses server sessions in Redis behind an HttpOnly cookie — instant logout, CSRF handled with SameSite — because for a browser session you want control. The mobile app and third-party API use short-lived JWTs (15 minutes) plus a server-stored refresh token — because for stateless, cross-domain, no-shared-session-store clients you want the scale, and the refresh anchor buys back just enough revocation. One system, two mechanisms, each where its trade-off wins.
And notice this is the same lesson the whole section keeps teaching in different clothes: REST vs gRPC, JSON vs Protobuf, one gateway vs BFFs — there is no universally correct choice, only a correct match between a mechanism's trade-off and a client's need. Auth is just the version where getting the match wrong ends up in a breach report.

Mental-Model Corrections
- "JWTs are encrypted — it's safe to put data in the payload." The payload is base64, readable by anyone — signature ≠ encryption. The signature proves nobody tampered; it hides nothing. Never put secrets in claims. (This one causes real breaches.)
- "JWTs are stateless, so they're just strictly more scalable." Stateless for the fifteen-minute life of an access token — then you need a stateful refresh token and often a deny-list lookup. "Stateless auth" is a window, not a permanent property; you traded instant revocation for the no-lookup read.
- "Sessions don't scale." They scale fine on a shared store (Redis serves millions of sessions); the cost is a per-request lookup and the store as a dependency, not a ceiling. Enormous apps run sessions on purpose — for the revocation.
- "AuthN and authZ are basically the same word." Two questions, two codes: 401 (I don't know who you are) vs 403 (I know exactly who you are, and no). You can be authenticated and forbidden.
- "Just trust the token's
algheader." That's thealg: none/ algorithm-confusion attack in one sentence. The server decides the algorithm; the token never gets a vote.
Try It Yourself: Forge a Token, Then Fail
Thirty minutes, and JWTs stop being magic:
- Read a real one. Grab any JWT (log into a site with dev-tools open, or make one at jwt.io). Split it on the dots into three parts and
echo '<part>' | base64 -dthe first two. There's your header and your claims, in plaintext — you just read a token's contents with zero credentials. That's base64-isn't-encryption, in your terminal. - Tamper, and get caught. In jwt.io, change a claim in the payload (
"admin": false→true). Watch the signature go invalid the instant you touch a byte — then paste the secret and watch it go valid again. The whole trust model, on one screen. - Forge the
alg: noneattack. Hand-build a token with header{"alg":"none"}, any payload you like, and an empty signature (header.payload.). Feed it to a naive verifier (a five-line script that readsalgfrom the header) and watch it accept your forgery. Then verify with a library configured to require HS256 — rejected. You just performed, and defended, a real CVE class. - Feel revocation. Write two tiny endpoints: one issues a JWT, one checks it. "Log out" — and realize you can't, because there's nothing to delete; the token stays valid until
exp. Now do the session version: a dict of session IDs, and logout justdels the key. Instant. Feel the difference in your fingers — it's the entire trade-off. - Bonus: set
expto 30 seconds from now, verify at t=0 (valid), wait, verify at t=40 (expired). Short-lived tokens, live.
Recap & What's Next
Access control is two questions, and the second one this section has been circling all along:
- AuthN (who are you? — 401) then authZ (what may you do? — 403): two questions, two codes, authN always first. You can be authenticated and still forbidden.
- HTTP forgets, so the server remembers you one of two ways — the §3 stateful/stateless fork in disguise.
- Sessions: server holds the truth, cookie is an opaque pointer. Instant revocation ("log out everywhere"), at the cost of a per-request lookup on a shared store (the same shared-state dependency that caused sticky sessions).
- JWTs: the signed token is the truth —
header.payload.signature, where base64 ≠ encryption (readable by anyone; secrets never belong there) and the signature buys trust without a lookup (any server verifies alone → real horizontal scale). The price: you can't un-sign it, so revocation needs short-lived access tokens + a stateful refresh token — statelessness with an asterisk. - Attacks all trace to "trusted math, not trusted lookup":
alg:none, algorithm confusion, secrets-in-payload, leaked secrets, endless expiry. Pin the algorithm, guard the secret, keep tokens short. - The answer is hybrid: sessions for web control, short JWTs + refresh for APIs and mobile. Match the mechanism to the client.
But one enormous question sits underneath everything today: we assumed the server has a secret and builds the token itself. What happens when you want to log in with "Continue with Google" — where a different company vouches for who you are, and your app trusts them without ever seeing your password? Delegated identity — OAuth2 and SSO — is the next lesson, and the finale of the APIs section.