CYBER-04-004-P · Primer — Sessions & JWT: Remembering a Login Without Re-Authenticating
The idea in one sentence
HTTP forgets you between every request, so after you log in the server needs some way to recognize your next request as "still that same logged-in person" without asking for your password again — and there are two fundamentally different ways to do it: hand you a random ID and keep the real facts about you on the server (a stateful session), or hand you the facts themselves wrapped in a tamper-proof signature you carry around (a self-contained token like a JWT) — and the whole trade-off between them comes down to one question: how fast can you take that login back?
Why it exists
HTTP is stateless. That's not a bug — it's a deliberate design choice that lets any server in a pool answer any request. But it has a sharp consequence: the server has no built-in memory of who is talking to it. Each request arrives naked. If you log in successfully on request #1, request #2 shows up looking exactly like a stranger's. Without something extra, you'd have to re-type your password on every single click — every page load, every image, every API call.
So we need a token of recognition: something the browser attaches to every subsequent request that says "I already proved who I am — here's my proof." The obvious place to put it is a cookie (RFC 6265): the server sends a Set-Cookie header once, the browser stores it, and then automatically replays it on every future request to that site. The cookie is the transport. The interesting question — the one this whole primer is about — is what you put inside it, because that choice quietly decides how your whole system scales, how it fails, and how hard it is to kick someone out.
There are two answers, and they sit at opposite ends of a spectrum.
Answer A — keep the memory on the server (stateful sessions). When you log in, the server creates a session record in its own storage — a database row or a Redis entry holding "user 4127, logged in at 09:14, is an admin" — and gives you only a long random session ID in the cookie. The ID is a claim check, not the coat. Every request, the server looks up the ID and reloads who you are.
Answer B — put the memory in the token itself (self-contained tokens / JWT). Instead of storing your facts server-side, the server writes them into the token — "user 4127, admin, expires 09:44" — and then signs it so nobody can tamper with a single character. The server keeps nothing. To recognize you later, it just re-checks the signature. The token is the memory.
Both solve the "HTTP forgets you" problem. They differ on where the truth lives — and that single difference cascades into everything else.
How it works
1. Stateful sessions & cookies — the ID is a pointer to server-held truth. On successful login the server generates a cryptographically random session identifier (long and unguessable — that randomness is the security; if an attacker can guess or steal a valid ID, they are you, which is session hijacking). It stores the real user facts against that ID in a session store and returns it via Set-Cookie. That cookie should carry protective flags (RFC 6265 and OWASP): HttpOnly so page JavaScript can't read it (blunting theft via XSS), Secure so it's only sent over HTTPS, and SameSite to limit when the browser attaches it cross-site (blunting CSRF). On each later request the browser auto-sends the cookie; the server does a lookup by ID and reconstitutes the session. The defining property: the server holds the state, so the server is in charge of it. Want to log someone out everywhere, instantly? Delete their row. The next request finds nothing and is rejected. The cost is that every request needs that lookup, and in a multi-server deployment all servers must reach a shared session store — which is real infrastructure to run.
2. JSON Web Tokens (JWT) — the truth travels with the request. A JWT (RFC 7519) is three Base64URL-encoded parts joined by dots: header.payload.signature. The header names the signing algorithm (e.g. alg: RS256). The payload is a set of claims — JSON key/values like sub (subject/user), exp (expiration time), iat (issued-at), iss (issuer), aud (audience). Crucial beginner correction: Base64URL is encoding, not encryption. Anyone can paste a JWT into a decoder and read the payload in plain text, so never put secrets in it. What protects a JWT isn't secrecy — it's the signature that makes it impossible to change without detection.
3. Token signing & validation — why a signed token needs no server lookup. The third part is a JSON Web Signature (RFC 7515), computed over the header and payload. Two families (RFC 7518): HMAC (e.g. HS256) uses one shared secret to both sign and verify — simple, but every verifier holds the signing power. Asymmetric (e.g. RS256, ES256) signs with a private key and lets anyone verify with the matching public key — so an auth service can sign while a hundred downstream APIs verify without being able to mint tokens. Validation is: recompute the signature and compare; then check the claims — is exp in the future? is iss/aud what you expect? Because verification needs only a key and a little math, any server can validate a JWT alone, with no database and no shared session store. That statelessness is the whole appeal: it scales horizontally almost for free and is why JWTs are the default carrier for OAuth 2.0 / OpenID Connect access tokens (RFC 6749).
4. Expiry, revocation & refresh — and the trade-off surfaces. Here's the catch that ties it all together. A stateful session can be killed the instant you delete its record. But a signed JWT is valid by definition for as long as its exp claim says — the server holds no copy to delete, so short of extra machinery there is no way to un-sign a token you already handed out. If you fire an employee at 10:00 and their JWT says exp: 10:30, it keeps working until 10:30. The standard mitigation is to keep access tokens short-lived (minutes) so the un-revokable window is small, and pair them with a longer-lived refresh token (RFC 6749): when the access token expires, the client silently trades the refresh token at the auth server for a new one. The refresh token is checked against server state — so revoking it stops future refreshes, which is how you claw back a stolen-token situation without giving up statelessness on the hot path. Many teams also run a small server-side denylist of revoked token IDs (the jti claim) — but notice what that admits: to get instant revocation back, you re-introduce exactly the server-side state JWTs were meant to escape.
The two designs are two ends of one dial. Sessions: state on the server → a lookup per request, shared store to run → but revocation is instant and total. JWTs: state in the token → no lookup, no shared store, effortless scale → but you traded away instant revocation and now manage it with short expiry, refresh, and sometimes a denylist. Neither is "more secure"; they optimize different things.
The one gotcha
A JWT cannot be un-issued — its expiry, not your "log out" button, decides when it dies — and the single most dangerous JWT bug is trusting the token's own alg header to tell you how to verify it. Two traps, both classics. First, revocation: developers assume "log out" invalidates a JWT the way it invalidates a session. It does not. Deleting a cookie only stops the browser from sending the token; the token itself is still perfectly valid and will be accepted by any server that sees it until exp passes. If you need real revocation (a logout that means something, a compromised account, a fired employee), you must add machinery: short lifetimes plus refresh, or a server-side denylist keyed on jti. Second, algorithm confusion: the JWT header carries alg, and a naive library will verify using whatever the token claims. Attackers exploit this two ways — setting alg: none to strip the signature entirely, or downgrading an RS256 token to HS256 so the server verifies an attacker-forged HMAC using the public key (which is, of course, public) as if it were the secret. Both let an attacker mint any token they like. RFC 8725 (JWT Best Current Practices) is blunt about the fix: reject none, and pin the expected algorithm and key on the server — never let the incoming token dictate how it's checked. Always validate the signature and the claims (exp, iss, aud); a token whose signature you never verified is just an attacker's suggestion.
See it — same request, two ways to recognize a returning user
sequenceDiagram
autonumber
participant B as Browser
participant S as Session server<br/>(stateful)
participant DB as Session store<br/>(DB / Redis)
participant J as JWT server<br/>(stateless)
B->>S: Login (password)
S->>DB: Save user facts,<br/>get random ID
S-->>B: Cookie = random ID
B->>S: Next request<br/>(cookie replayed)
S->>DB: Look up ID,<br/>reload user facts
Note over S,DB: Revoke = delete row<br/>(instant, total)
B->>J: Login (password)
J-->>B: Signed JWT<br/>(facts + exp inside)
B->>J: Next request<br/>(token attached)
Note over J: Re-check signature +<br/>claims, no lookup
Note over J: Cannot un-issue,<br/>valid until exp
The one-line takeaway
A session keeps your identity on the server and hands you a random claim-check, so you can be logged out instantly but every request pays for a lookup; a JWT hands you your signed identity to carry, so any server verifies you with no lookup but nobody can take that token back before it expires — pick based on whether effortless scale or instant revocation matters more, and secure the JWT by pinning the algorithm, keeping it short-lived, and pairing it with a revocable refresh token.
Where you'll meet it
You'll meet this the first time you build a login and have to choose between express-session with a Redis store versus signing a JWT — and again the day support asks you to "force-log-out this compromised account right now," when you discover that your stateless JWT design has no instant off switch and you're suddenly bolting on a jti denylist. You'll meet it debugging a cookie that isn't being sent and learning what HttpOnly, Secure, and SameSite actually do, and debugging a cross-origin API where cookies are awkward and a bearer JWT in the Authorization header is cleaner. You'll meet it every time you integrate OAuth 2.0 / OpenID Connect, where the access token is typically a short-lived JWT and the refresh token is the long-lived, revocable one you guard carefully. And you'll meet it in security review, where the reviewer checks that you reject alg: none, pin the verification algorithm and key server-side rather than trusting the token's header, and validate exp, iss, and aud on every request. Interviewers love this exact fork: "session vs JWT — when would you pick each?", "how do you log someone out of a JWT-based system before the token expires?", and the one that separates levels, "why is trusting the JWT's own alg header dangerous?" The strong answer never says one is simply better — it names the trade: server-side state buys instant revocation at the cost of a lookup and a shared store, self-contained signed tokens buy effortless scale at the cost of revocation you have to re-engineer with short expiry and refresh.
Named concepts: stateful sessions & cookies, JSON Web Tokens (JWT), token signing & validation, expiry, revocation & refresh
