<img height="1" width="1" style="display:none" src="https://www.facebook.com/tr?id=1063935717132479&amp;ev=PageView&amp;noscript=1 https://www.facebook.com/tr?id=1063935717132479&amp;ev=PageView&amp;noscript=1 "> Sessions and Tokens

Sessions and Tokens

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.

Client Server POST /login (username + password) 200 OK + credential …and instantly forgets you GET /orders + credential 200 OK: it's you GET /profile + credential 200 OK: it's you the credential rides along on every request: a session ID or a token
HTTP is stateless, so the server forgets the client after every response. At login the server hands back a credential, and the client attaches it to every request after that to prove it's still the same user.

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.

Client Server Session store POST /login create { user: 1138 } sess_a9f3 Set-Cookie: sid=sess_a9f3 the truthlives here GET /orders Cookie: sid=sess_a9f3 look up sess_a9f3 { user: 1138 }: valid 200 OK: it's you the client holds an opaque ID, every request costs a lookup, and logout is just deleting the row
The session data never leaves the server. The client only ever holds the opaque ID. Every request after login costs the server a lookup in the session store.
  • 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 set alg: none to 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/nbf automatically; validating iss and aud keeps a token minted for one service from being replayed against another.
    Claim Name Purpose
    iss Issuer Who created and signed the token
    sub Subject Who the token is about, usually the user ID
    aud Audience Who may accept the token
    exp Expiration time Rejected after this moment
    nbf Not before Rejected before this moment
    iat Issued at When the token was created
    jti JWT 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.

  1. Fix the expiry. The token is signed with expiresIn: "2s" but jwt.verify runs inside a setTimeout at 5 s. The token is already expired by then and throws a TokenExpiredError. Change the expiry so the delayed verify succeeds.
  2. Add a new claim. Extend the payload with at least one extra claim (e.g. email, plan, or department), then log it from the laterClaims result to confirm it round-trips through the token.

Check your understanding

Question 1 of 2

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.