Sessions and tokens

Stateful versus stateless, the revocation you give up with a JWT, access and refresh tokens, and where a browser should keep one.

7 min read🛡️ Application Security

HTTP forgets you between requests. Every scheme for staying logged in is a way of handing the client something it gives back, and the whole design space is one question: does the server remember what it issued, or does it verify what comes back?

That single choice decides how you log someone out, how you scale, and what happens when a token is stolen.

Sessions: the server remembers

The server stores the session — who it belongs to, when it started, anything else — and gives the client an opaque id in a cookie.

plaintext
Set-Cookie: JSESSIONID=8f2c...; HttpOnly; Secure; SameSite=Lax; Path=/

The id means nothing by itself. It is a key into server-side state, so every request is a lookup.

What that buys you is control. Because the server holds the record, it can change its mind: revoke a session the instant a user logs out, invalidate every session when a password changes, list a user's active devices, and end one from an admin screen. Nothing is trusted that the server is not currently willing to honour.

What it costs is state. That store has to be reachable by every instance, which in practice means Redis rather than memory — the same lesson the rate-limiting section reached from a different direction. In-memory sessions behind a load balancer mean sticky routing, and sticky routing means a deploy logs people out.

The cookie flags are not optional, and each prevents a specific attack:

  • HttpOnly — JavaScript cannot read it, so an XSS flaw cannot steal the session.
  • Secure — sent over HTTPS only, so it is not disclosed on a plain-HTTP request.
  • SameSite=Lax or Strict — not sent on cross-site requests, which is most of CSRF handled by the browser.
  • Path, and a sensible expiry.

Tokens: the server verifies

The alternative is to give the client something that carries the facts and a signature, and check the signature on arrival. A JWT is the usual form: a header, a set of claims, and a signature over both.

plaintext
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMiLCJleHAiOjE3...

No lookup. Any instance can verify it with a key, and so can a different service that was never told about your session store. That is the real win, and it is why tokens dominate in systems with many services and clients.

The cost is the mirror image of the benefit. The server has no record, so it cannot take it back. A token is valid until it expires, and "log out" is a client-side gesture: the client throws the token away, and a copy taken beforehand keeps working. Nothing you do on the server changes that.

The trade, stated plainly

SessionJWT
where the truth livesserverthe token
revocationimmediatenot possible before expiry
per-request costa store lookupa signature check
works across servicesneeds shared storeyes
size on the wiretiny idthe whole payload, every request
stale dataneveruntil the token expires

That last row is underrated. A JWT carrying roles: ["ADMIN"] keeps asserting it after the role is removed. If your token lives an hour, a revoked administrator is an administrator for up to an hour.

So the rule is: the shorter the life, the closer a token gets to being revocable — and short-lived tokens need a way to be renewed. Which is what refresh tokens are.

Access and refresh, and why the split exists

  • An access token is short-lived (5–15 minutes), sent on every request, and not stored server-side.
  • A refresh token is long-lived, sent only to one endpoint, and is recorded server-side — so it can be revoked.

The split puts the revocable thing on the rare path and the fast thing on the hot path. Revoking a user means deleting the refresh token; within fifteen minutes their access token expires and cannot be renewed. That is not instant revocation, and it is close enough for most systems — as long as you choose the window deliberately rather than inheriting a default of 24 hours.

Refresh token rotation is the part that is usually missing: each refresh issues a new refresh token and invalidates the old one. If an old one is ever presented again, it was stolen — and the correct response is to revoke the whole family, because you now know one of the two parties is an attacker and cannot tell which.

Where to keep a token in a browser

This question causes more argument than it deserves, and the options are genuinely all imperfect:

  • localStorage — readable by any JavaScript on the page. One XSS flaw and the token is gone. Convenient; the weakest option.
  • A cookie with HttpOnly — script cannot read it, so XSS cannot steal it. The browser sends it automatically, which reintroduces CSRF, which SameSite and a CSRF token handle.
  • In memory only — safest, and lost on every page refresh, so it needs a silent-refresh flow.

The honest summary: HttpOnly cookies for a browser app, Authorization headers for a mobile or service client. And note what the first row really says — if you have an XSS vulnerability, token storage is not your problem, XSS is.

What never goes in a token

A JWT is signed, not encrypted. Anyone holding it can read every claim — it is base64, not a cipher, and a paste into any decoder shows the contents.

So: no email addresses if you can avoid it, no internal ids you would rather not publish, no permissions list so detailed it maps your authorisation model, and never anything secret. And keep it small; it travels on every single request.

Two validation details that are skipped surprisingly often, both of which have caused real breaches:

  • Check the algorithm. A library that honours the token's own alg header will accept alg: none — an unsigned token asserting anything. Pin the expected algorithm in your configuration.
  • Check iss and aud. A validly signed token from a different service, or intended for a different audience, is still validly signed. Signature alone does not mean for me, from someone I trust.
Progress is saved on this device and to your account when signed in.