When JWT Auth Turns Stateful: A Case for Server-Side Sessions in Node
Blocklists and refresh stores make JWT authentication stateful anyway. Postgres-backed Express sessions in Node.js are simpler, and where JWTs still fit well.
This article is published in English.
Sources: Forget JSON Web Tokens: Server-Side Sessions Cut Our Auth Bugs By 70%
Stateless JWT authentication looks elegant on a whiteboard, but many teams discover that logout, forced sign-out and password resets quietly drag state back in. Once a revocation blocklist, a refresh token store and custom middleware are all involved, the auth layer becomes the most fragile part of the system. This article walks through that failure pattern, shows how a Postgres-backed session setup in Express replaces it, and explains the narrow cases where JWTs remain the better tool.
A familiar failure pattern
Picture a team that is proud of its JWT setup until the support queue fills up. Users are logged out at random, password resets stop taking effect, and one customer files three tickets in a single day, each describing the same paradox: he is logged in and not logged in at the same time. Engineers spend four hours on that one ticket, decoding his token and checking its expiry, and everything looks valid.
Bugs like this usually live in the seams between mechanisms, each added to patch a limitation of the last.
Why "stateless" rarely stays stateless
The standard pitch for JWTs is appealing. There is no session store and no database lookup: you put the user ID in a payload, sign it, hand it to the client, and let the token itself be the source of truth.
The catch is revocation. A signed token stays valid until it expires, and the server has no built-in way to cancel it early. Logging a user out everywhere, reacting to a stolen token or invalidating access after a password change all require a token blocklist, which is exactly the kind of shared state the design was supposed to eliminate.
In the scenario above, the team ends up running a Redis blocklist for revoked tokens, a separate store for refresh tokens and a custom middleware layer that ties them together. A system adopted for being stateless has become the most stateful component in the architecture.
What the per-request check turns into
A simplified version of the middleware that runs on every request looks like this. It pulls the bearer token from the Authorization header, looks it up in the Redis blocklist, and then verifies the signature and claims with jwt.verify.
// verify access token
function verifyToken(req, res, next) {
const token = req.headers.authorization?.split(" ")[1];
if (!token) return res.status(401).json({ error: "No token" });
// check if revoked
const revoked = await redis.get(`revoked:${token}`);
if (revoked) return res.status(401).json({ error: "Token revoked" });
jwt.verify(token, process.env.SECRET, (err, decoded) => {
if (err) return res.status(401).json({ error: "Invalid token" });
req.user = decoded;
next();
});
}
As written, the function would not even run: it uses await without being declared async. The larger problem is architectural. Every request now makes a network call to Redis, so the central benefit of JWTs, avoiding a lookup, is gone. On top of that you pay the CPU cost of verifying the token and a separate pass over expiry and claims.
The bugs hide in the gaps between those three checks:
- An access token expires, and the refresh call fails silently.
- The blocklist key in Redis expires before the JWT it was meant to block, so a revoked token becomes valid again.
- A logout request races the next API call, and the user is logged out and back in within the same second.
Each of these needs a fix in a different layer, and a fix in one layer can easily break an assumption in another.
Replacing three mechanisms with one session store
The alternative is server-side sessions. The client holds only an opaque session ID in a cookie, and the session data lives in your database. In Express, express-session with the connect-pg-simple store keeps sessions in Postgres:
const session = require("express-session");
const PgSession = require("connect-pg-simple")(session);
app.use(session({
store: new PgSession({ pool: db }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: true,
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
}
}));
A few settings do most of the security work. httpOnly keeps the cookie out of reach of JavaScript in the page, which blunts token theft through XSS. secure ensures it is only sent over HTTPS. resave: false avoids writing unchanged sessions back on every request, and saveUninitialized: false stops the store from filling up with empty sessions for anonymous visitors. The maxAge here gives sessions a seven-day lifetime. Note that connect-pg-simple expects its session table to exist, so create it as part of your migrations.
The operations then become almost trivial:
- Login sets
req.session.userId. - Logout calls
req.session.destroy(). - A forced logout deletes the user's rows from the sessions table.
Revocation is now a single delete, with no blocklist expiry to drift out of sync, and every request reads the same authoritative row. Two hardening steps are worth adding: call req.session.regenerate() on login to prevent session fixation, and set sameSite on the cookie (plus CSRF protection where needed), because cookies are sent automatically by the browser in a way bearer headers are not.
In the scenario described here, the migration deleted roughly 340 lines of auth logic, and every deleted line was one less place for a bug to hide.
Measuring the change
To judge the switch fairly, the team tracked every auth-related bug report for 90 days before the migration and for 90 days after. Their reported numbers:
Before (JWT):
- Auth bugs reported: 43
- Avg debug time per bug: 2.4 hours
- Force-logout capability: not possible
After (Sessions):
- Auth bugs reported: 13
- Avg debug time per bug: 0.6 hours
- Force-logout time: < 1 second (delete row)
Reduction: ~70%
Bug reports fell from 43 to 13, roughly a 70% reduction, and average debugging time per bug dropped from 2.4 hours to 0.6. Forced logout went from impossible to a row deletion taking under a second. These are one team's figures, not a benchmark.
The usual objection is the database lookup on every request. For most applications, a primary-key read of a sessions table is cheap compared with the queries a typical request already runs, so the real bottleneck is almost always elsewhere.
Comparing the two request paths
Drawn side by side, the difference is plain. The JWT path has the server decode the token, call out to Redis for the blocklist, and validate expiry and claims before the route handler runs:
Client --> [Token in header] --> Server
|
Decode token (CPU)
|
Check Redis blocklist (network)
|
Validate expiry + claims
|
Route handler
The session path has the server read the session from Postgres, then hand off to the route handler:
Client --> [Session cookie] --> Server
|
Lookup session in Postgres
|
Route handler
One lookup against one source of truth means that when something breaks, there is exactly one place to look.
Where JWTs still belong
None of this makes JWTs bad. They are a good fit for short-lived, purpose-specific tokens: email verification links, password reset flows, and other self-contained proofs of intent that expire within minutes and never need to be revoked mid-flight. A verification token like the one below carries the user ID and its purpose, and expires after 15 minutes:
// email verification — expires fast, never needs revocation
function makeVerifyToken(userId) {
return jwt.sign({ userId, purpose: "verify" }, process.env.SECRET, {
expiresIn: "15m"
});
}
Including a purpose claim matters, because it prevents a verification token from being accepted wherever another type of token is expected. Keep in mind that a JWT alone cannot guarantee single use; if a reset link must work only once, record that it was consumed, or tie the token to a value that changes after use, such as the current password hash.
The specification was never the problem. The problem was stretching it to cover persistent, revocable, long-lived user sessions, which is the job sessions were designed for.
Why teams hold on to complex auth
Sessions feel old and JWTs feel modern, and tutorials on JWTs vastly outnumber those on sessions. That imbalance shapes what feels correct.
Experienced engineers often lean the other way and pick boring solutions. Enough production incidents teach them that boring systems rarely page anyone at night, and server-side sessions have worked reliably for decades, long before Redis existed.
Choosing your approach
- Starting a new web app? Default to server-side sessions: an
httpOnly,secure,sameSitecookie, sessions stored in Postgres, the session regenerated on login and destroyed on logout. For a broader comparison, see our guide to choosing between sessions and JWTs. - Running a JWT setup that works cleanly? Leave it alone; migrating for its own sake adds risk.
- Already juggling refresh tokens, a blocklist and hand-written invalidation? Then sessions exist in your system already, only with more infrastructure, more failure points and more debugging hours.
The best engineering decision is often the one that removes code. Fewer lines leave fewer places for bugs, and fewer layers make the remaining bugs faster to find.