Authentication answered "who are you?" once, at login. The problem: HTTP is stateless: the server forgets you the instant a response is sent. So after a successful login, how does the next request prove it's still you? The server hands the client a credential at login, and the client sends it back on every subsequent request. There are two ways to design that credential, and the choice shapes how your whole system scales.
So what is that credential? There are two designs. Either the server keeps the state and hands the client a pointer to it, or the server hands the client the state itself, signed so it can't be forged. The first is a server-side session; the second is a token.
Server-Side Sessions
In the session design, the server stores the session data (who you are, when you logged in) in its own store (memory, Redis, a database) and hands the client only an opaque random session ID , usually in a cookie. On each request the server looks the ID up.
- Stateful. The truth lives on the server. To scale past one server, every instance needs to reach a shared session store.
- Easy to revoke. Logging out, or banning a user, is just deleting the row. The next request fails to look up.
That shared session store is the catch: it's one more piece of infrastructure, and every server has to reach it on every request. The second design exists to remove it.
JSON Web Tokens (JWT)
A JWT stores nothing on the server. At login, the server signs a token containing the user's claims and hands the whole thing to the client. On each request the server verifies the signature, no lookup needed, because the token carries its own data.
Look at one and you'll see two dots. They split the token into three parts (a header, a payload, and a signature), each base64url-encoded:
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMTM4Iiwicm9sZSI6ImVkaXRvciJ9.K3v...signature
└────── header ──────┘ └──────────── payload (claims) ───────────┘ └ signature ┘ Heads-up: the payload is signed, not encrypted: it's only base64-encoded, so anyone holding the token can read every claim in it. The signature stops tampering, not reading. Never put secrets in a JWT, and treat it like the password it effectively is. The signing key can be a shared symmetric secret (HMAC/HS256) or an asymmetric private key (RS256/ES256). The latter is common in OIDC, where the provider signs with a private key and the relying party verifies with the provider's public key.
In code, the whole lifecycle is two calls: sign at login, verify on every request:
import jwt from "jsonwebtoken";
// At login: sign a short-lived token with the user's claims
const token = jwt.sign({ sub: user.id, role: user.role }, SECRET, { expiresIn: "15m" });
// On each request: verify the signature and expiry (no database lookup).
// Always pin the algorithm list; never let the token pick its own.
const claims = jwt.verify(token, SECRET, { algorithms: ["HS256"] }); // throws if tampered with or expired Why pin
algorithms? The header names its own signing algorithm, and a verifier that trusts it can be tricked. An attacker can setalg: noneto skip verification entirely, or downgrade an RS256 system to HS256 so the server's public key gets used as an HMAC secret, letting the attacker forge valid signatures. The fix: the server decides which algorithms it accepts, never the token.
What goes in the payload
The payload is just JSON, but the spec and the community have settled on conventions. Claims come in three flavors:
- Registered claims: seven names reserved by the spec, all optional but understood by every JWT library.
Libraries enforce
exp/nbfautomatically; validatingissandaudkeeps a token minted for one service from being replayed against another.Claim Name Purpose issIssuer Who created and signed the token subSubject Who the token is about, usually the user ID audAudience Who may accept the token expExpiration time Rejected after this moment nbfNot before Rejected before this moment iatIssued at When the token was created jtiJWT ID Unique token ID, handy for denylists - Public claims: community-standardized names, mostly from OpenID Connect:
email,name,picture,email_verified, and friends. Use these instead of inventing your own spellings. - Private claims: whatever your services agree on:
role,plan,tenant_id. To dodge collisions with future standard claims, many teams namespace them (https://myapp.com/role); Auth0 requires it.
Two rules of thumb. Keep it small: the payload rides along on every request, and headers get rejected past a few KB. Store the user ID, not the user. And remember it's readable: no secrets, no PII you wouldn't put in a URL.
The trade-offs mirror the session design's, point for point:
- Stateless. Any server with the key can verify the token, so horizontal scaling is trivial: no shared session store.
- Hard to revoke. The token is valid until it expires; the server isn't consulting a store it can delete from. That's why access tokens are kept short-lived (minutes) and paired with a longer-lived refresh token used to mint new ones.
Which to Use
Every row in this table is the same trade wearing a different outfit: sessions keep the truth on the server, JWTs hand it to the client. Decide where the truth lives and everything else follows.
| Server-side session | JWT | |
|---|---|---|
| Where's the truth? | On the server, in a store | In the client's hands, signed |
| What does each request cost? | A store lookup | A signature check, pure CPU |
| What does scaling take? | Every server wired to a shared store | Copying one key to the new server |
| How fast can you kick someone out? | Instantly: delete the row | You can't. You wait for expiry, or rebuild a store as a denylist |
| What can the client read? | Nothing: the ID is opaque | Every claim in the payload |
Notice the pattern in the JWT column: every strength comes from having no store, and every weakness is the price of not having one. The revocation row deserves special attention. It's the one people get burned by. If "this account is compromised, cut it off now" is a requirement, a bare JWT can't deliver it.
So: sessions when you control the whole stack and instant logout matters, the classic web app. JWTs when there is no shared store to reach: stateless APIs, service-to-service calls, horizontal scale. And in practice, most real systems use both: a session cookie where the user logs in, short-lived JWTs between the services behind it.
Node challenge · runs in your browser
JWT sandbox
Estimated time: ~10 minutes
A live sandbox running real jsonwebtoken in Node. Hit Run in Node. The code will fail. Your job is to fix it.
- Fix the expiry. The token is signed with
expiresIn: "2s"butjwt.verifyruns inside asetTimeoutat 5 s. The token is already expired by then and throws aTokenExpiredError. Change the expiry so the delayed verify succeeds. - Add a new claim. Extend the payload with at least one extra claim (e.g.
email,plan, ordepartment), then log it from thelaterClaimsresult to confirm it round-trips through the token.
Check your understanding
Does the forged role take effect?
You store a user's role inside a JWT and decide permissions from it. An attacker edits the payload to `"role":"admin"`. Does the change take effect?
Whichever design you pick, the client ends up holding a credential, and in a browser, where it holds that credential is its own security decision. That's the next lesson.