You sign into a dashboard on Monday, close the laptop, and open it again on Wednesday. You are still signed in. Nothing about that feels remarkable, yet a chain of decisions inside the application made it happen — and those decisions are the difference between an authentication system that holds up and one that quietly leaks accounts.
Two approaches dominate: server-side sessions carried in cookies, and signed tokens, usually JWTs. They are often presented as rivals. Really they solve the same problem in different places, and the trade-offs only become clear once you think about revocation and scale.
Cookie sessions: the server keeps the receipt
When you log in, the server creates a session record — user ID, roles, creation time, expiry, perhaps the IP address — and writes it to a store: Redis, Postgres, a file, whatever is at hand. Your browser gets a cookie holding a long random identifier and nothing else.
The cookie is not the session. It is a pointer to it. Every request carries that pointer, middleware looks it up, and the server decides who you are. Because the record lives on the server, you can change your mind about it at any moment: delete the row and the user is signed out on the next request.
Set the cookie flags properly and this is genuinely hard to attack:
- HttpOnly so JavaScript cannot read it, which blunts cross-site scripting.
- Secure so it never travels over plain HTTP.
- SameSite=Lax (or Strict for sensitive apps) to cut down cross-site request forgery.
- The __Host- name prefix, which forces Secure, a path of / and no Domain attribute.
Generate the identifier with a cryptographically secure random number generator. 128 bits of randomness is a sensible floor. Sequential IDs, user IDs, or anything derived from an email address are a gift to an attacker who fancies guessing.
Where sessions get awkward
A single server with sessions in memory works beautifully until you run two. Then a user hops between instances and gets logged out at random. Sticky sessions patch the symptom but make deploys and autoscaling irritating, so most teams move sessions into a shared store such as Redis.
That introduces a lookup on every authenticated request. In practice it is a fraction of a millisecond, and it buys you instant revocation — a trade most applications should happily make. Keep the store close to the app, use a replica for reads if traffic justifies it, and give sessions both an idle timeout and an absolute lifetime.
Tokens: the receipt is the claim
A JWT is three base64url chunks separated by dots: a header describing the algorithm, a payload of claims, and a signature. Anyone holding the token can read the payload — it is encoded, not encrypted. The signature is what matters. It proves the token was issued by someone holding the key and that nothing inside has been altered.
Typical claims include a subject (sub), issuer (iss), audience (aud), expiry (exp), issued-at (iat) and a unique token ID (jti). Verification is done with a shared secret (HS256) or, better across multiple services, a public key (RS256 or ES256) fetched from the issuer's key endpoint.
The appeal is that no lookup is needed. Any service with the key can verify a token on its own, which suits microservices, mobile apps and third-party API clients. The cost is exactly what made it appealing: if nothing is stored, there is nothing to check the token against. Logging out on the client does nothing to the token itself. It stays valid until it expires, so keep access tokens short — five to fifteen minutes is common — and treat them as disposable.
Where to keep a token in the browser
In localStorage, any script on the page can read it, so one XSS bug hands over the account. In a JavaScript variable it survives only until refresh. In an HttpOnly cookie it is safe from script access but needs CSRF protection — which puts you back where sessions started. That last option is usually the least bad, and it is worth admitting that in-browser JWTs are often sessions with extra steps.
Refresh tokens and the two-token pattern
The workable pattern pairs a short-lived access token with a long-lived refresh token that is stored and rotated on the server:
- Login returns a short access token plus a refresh token, ideally in an HttpOnly cookie scoped to the refresh endpoint.
- The client uses the access token until a request returns 401.
- It calls the refresh endpoint; the server checks the refresh token against its store and issues a new pair, invalidating the old refresh token.
- If an already-used refresh token appears again, someone has a copy. Revoke that entire token family and force a fresh login.
That last step matters more than the rest. Rotation with reuse detection gives token-based systems the revocation they otherwise lack.
Revocation, honestly
Sessions revoke by deletion. Change the user's password and you clear their sessions; you can even store a "valid after" timestamp on the user record and reject anything older.
JWTs have three practical answers: keep expiry short, maintain a denylist of token IDs in Redis until they expire naturally, or carry a token version claim and bump it on the user record for sensitive actions. The denylist reintroduces the lookup you were avoiding, and that is fine — just be clear-eyed about it.
If you need a token to be revocable on every single request, you do not want stateless tokens. You want a session with a fast store.
Pitfalls that cause real incidents
- Verifying the signature but skipping exp, aud or iss, so a token minted for another service sails through.
- Accepting the algorithm named in the token header instead of pinning the one you expect.
- Sharing one HS256 secret across every service in the estate. Use asymmetric keys and rotate them.
- Cookie-based auth on state-changing endpoints that happily accept cross-site form posts.
- Trusting a role claim for authorisation after the role was revoked upstream.
- Refresh tokens that never rotate, or that sit in the same storage as the access token.
- Secrets committed to a repository, and no logging of login, refresh and revocation events.
A practical way to choose
For a server-rendered monolith on one domain, use sessions in an HttpOnly cookie backed by Redis. Fewer moving parts, instant logout, easy to reason about at 2am. For several services, mobile clients or public API consumers, issue short-lived JWTs signed with an asymmetric key and pair them with rotating refresh tokens. Mixing the two is normal: a session cookie for the browser app, tokens for machine-to-machine calls.
Whichever you pick, do four things now. Enforce the cookie flags or pin the signing algorithm. Set realistic expiry and timeout values rather than leaving defaults. Build the revocation path before you need it — delete a session, rotate a refresh token, bump a version claim. Then test the scenario everyone forgets: a user is dismissed at 9am and their access must be gone by 9:01. If your system cannot pass that, the design is not finished.
Photo: 1139623 / Pixabay


