← Back to Articles Backend Engineering

Stateful Sessions in Redis Instead of JWT: Why I Skipped Tokens

Oselio Candido · Jul 2026 · 7 min read

The problem

Ask the internet how to do auth in 2026 and the answer is JWT before you finish the question. For a B2B SaaS product where an account admin needs to be able to cut off an employee's access, that default has a hole in it: a JWT is valid until it expires, not until you change your mind. Fire an employee at 14:00 and their 15:00 token still works — the signature is still mathematically valid, and no server-side check exists to say otherwise unless you build one.

That gap wasn't cosmetic. Instant revocation — a manager removing someone's access and having it take effect on their very next request — was a hard requirement, not a nice-to-have. Any auth model that couldn't guarantee that wasn't a candidate, no matter how popular it was.

What is stateless vs. stateful auth

A JWT is a signed statement: "this is user 42, role admin, valid until 15:00." The server verifies the signature and trusts the contents — no lookup, no storage. That's the sales pitch: any server with the public key can authenticate a request, which is genuinely useful when many independent services need to verify identity without sharing a database.

A stateful session inverts the trade: the cookie carries a meaningless random identifier, and everything true about the session — who the user is, what role they hold, when the session expires — lives server-side in a fast key-value store. Every request costs one lookup against that store; in exchange, deleting the corresponding record kills the session immediately, on the very next request.

The alternatives

Three shapes were realistically on the table:

  • Plain JWT, no server-side state. Simplest to build, and the "modern" default every tutorial reaches for. Rejected outright: it cannot satisfy instant revocation without contradicting its own premise.
  • JWT with short-lived access tokens plus a refresh token or a server-side blocklist. This is the standard mitigation people reach for once they notice the revocation problem. It narrows the exposure window, but it quietly reintroduces server-side state — a blocklist to check, or a refresh-token store to manage — at which point you're running stateful auth with extra moving parts and a signature verification step bolted on. It doesn't actually buy back the statelessness that made JWT attractive in the first place.
  • Plain server-side sessions, keyed by a random ID. One lookup per request against a store that already sat in the stack for caching. Revocation is a single delete. The scalability argument for JWT — many stateless services verifying identity independently — didn't apply either: this is a modular monolith, not a mesh of independently-scaling services.

The decision table came down to three points: instant revocation was non-negotiable, the multi-service scalability case for JWT didn't describe this system, and a session lookup against an in-memory store already in the architecture was cheap enough to be a non-issue.

The decision

Sessions won. On login, a random session identifier and a separate random CSRF token are generated, and a single key is written to the store with a time-to-live drawn from the configured session expiry window. The TTL is the quiet hero of the design: it lives inside the store itself, so an abandoned session doesn't need a cleanup job — it simply evaporates when the clock runs out. That expiry is also sliding: every authenticated request re-writes the key with a fresh TTL, so active users stay logged in and idle ones time out on schedule.

Two cookies go to the browser, with deliberately different flags. The session identifier is httpOnly, so client-side JavaScript can never read it — an XSS payload can't exfiltrate it. The CSRF token, by contrast, is readable by JavaScript on purpose. Both are marked secure and samesite=strict.

Defending against CSRF

Cookie auth has one classic weakness a bearer token in a header doesn't: the browser attaches cookies automatically, even to requests a malicious page tricks it into sending. The defense is to require something a forged cross-site request can't reproduce, checked in two layers: the CSRF cookie must match a request header the frontend sets explicitly, and that header must match the CSRF token stored server-side inside the session record itself. Both comparisons run in constant time, so a timing side channel can't be used to guess the correct value byte by byte.

The single-page app reads the CSRF cookie and attaches it as a custom header on every mutating request. A malicious page can make the browser send the session cookie automatically, but it has no way to read the CSRF cookie's value — same-origin policy blocks that — so it can't construct a matching header. Binding the token to the session record on top of the cookie/header pair closes a subtler hole: even a correctly-shaped token pair is rejected unless it matches what the server actually issued at login, so a stale or replayed token from a different session fails as well. A missing session means an unauthenticated response; a missing or mismatched CSRF token means a forbidden response. Every protected route enforces this the same way, by construction, so no individual endpoint can forget to apply it.

What logout means — and the gap I was honest about

Logout is one line of intent: delete the session record, clear both cookies. Gone means gone — the next request carrying the old session cookie finds no matching record and is rejected immediately. No blocklist, no waiting for a token to expire on its own. This is the property a pure JWT scheme couldn't give without reinventing server-side state.

The honest gap: "log out of all devices" was one of the motivating requirements for choosing a stateful model, and it wasn't fully built at the point this decision was made. Sessions were keyed only by their own random ID, with no index from a user back to all of that user's active sessions — so a single session could be killed instantly, but all of a user's sessions couldn't yet be enumerated and killed together. What mattered was that the chosen architecture made that a small, additive feature rather than a rearchitecture: a per-user index of session IDs, populated at login and swept at logout, slots directly into the existing model. With a pure JWT scheme, the same requirement would have forced a structural rework instead of an addition. That was the real bet — choosing the model where the hard requirement stays cheap to satisfy, even before every corner of it is built.

The same session mechanism does double duty for multi-factor authentication: a pre-authentication "challenge" state — password verified, one-time code still pending — is stored as a short-lived session entry with its own tighter fixed expiry and its own cookie, reusing the same store and the same revocation guarantees rather than inventing a parallel mechanism (details in the TOTP write-up).

Choosing this model came with trade-offs that were accepted deliberately rather than overlooked. The session store becomes auth-critical: if it's unreachable, nobody can authenticate. That was acceptable because the store already backed caching elsewhere in the system, and a restart logging every user out is an inconvenience, not data loss. Every authenticated request now costs one extra lookup, sub-millisecond against a store on the same host — not a bottleneck at the traffic this system actually sees. And horizontal scaling requires every application instance to point at the same shared store, which is a non-issue for a monolith but would be a real constraint in a service mesh with many independently-scaling components — which is precisely the scenario where JWT's stateless-verification advantage would actually earn its complexity. It isn't this system's scenario.

The outcome

Access revocation became a guarantee instead of a best-effort window: deleting one record ends a session on its very next request, with no dependence on a token's remaining lifetime. The sliding TTL removed an entire category of cleanup code — expired sessions clear themselves out of the store without a scheduled job. CSRF protection is enforced uniformly across every protected route by construction, rather than being something each endpoint has to remember to implement correctly. And the one requirement that wasn't fully delivered at the time — logging out of all devices at once — was left as a documented, architecturally cheap addition rather than a silent gap, because the model chosen made it an index away instead of a redesign.