This article is published in English.
Sessions vs. JWTs: Choosing the Right Node.js Auth Model
Learn how sessions and JWTs actually differ in Node.js authentication, where each one breaks down, and how to choose between them without regretting it later.
The Problem Underneath Both Approaches
HTTP has no built-in memory of anything from one request to the next. Every incoming request looks like it's coming from a total stranger unless it carries proof of identity along with it. Sessions and JWTs both solve this in the same fundamental way: they give the client a piece of data to hand back on each subsequent request. What actually separates them is the nature of that piece of data, and more importantly, where the authoritative record of "who is logged in" actually resides.
Option One: Sessions
In a session-based setup, the server holds the authority. Once a user logs in, the server creates a random session identifier, saves the real user information against that identifier somewhere like Redis or a database, and gives the client nothing but the identifier itself, usually stored in a cookie.
app.post("/login", async (req, res) => {
const user = await authenticate(req.body.email, req.body.password);
const sessionId = generateSecureId();
await redis.set(`session:${sessionId}`, JSON.stringify({ userId: user.id }), "EX", 86400);
res.cookie("sessionId", sessionId, { httpOnly: true, secure: true });
res.json({ success: true });
});
app.use(async (req, res, next) => {
const sessionId = req.cookies.sessionId;
const session = await redis.get(`session:${sessionId}`);
req.user = session ? JSON.parse(session).userId : null;
next();
});
From that point forward, every incoming request triggers a lookup wherever the session data lives. That single mechanism explains both why sessions are useful and why they carry overhead.
The upside: you get immediate and complete control over who stays logged in. Ending a session, whether triggered by a user logging out, a password reset, or an administrator shutting down a compromised account, is just a deletion in the session store. There's no waiting around for anything to time out naturally.
The tradeoff: every request now requires a round trip to the session store, adding a small but real amount of latency and infrastructure load. It also turns your session store into shared state that every one of your servers has to reach, which becomes a real architectural concern the moment you scale beyond a single server.
Option Two: JWTs
A JWT (JSON Web Token) works differently: the server signs a token that already contains the user's data, and the client resends that exact token with every request. The server checks the signature and trusts what's inside without needing to look anything up elsewhere.
app.post("/login", async (req, res) => {
const user = await authenticate(req.body.email, req.body.password);
const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET, { expiresIn: "1h" });
res.cookie("token", token, { httpOnly: true, secure: true });
res.json({ success: true });
});
app.use((req, res, next) => {
try {
const decoded = jwt.verify(req.cookies.token, process.env.JWT_SECRET);
req.user = decoded.userId;
} catch {
req.user = null;
}
next();
});
There's no database call and no Redis call involved. The cryptographic signature by itself is enough to confirm the token hasn't been tampered with since it was issued.
The upside: there's no per-request database or cache lookup, which makes things measurably faster, and there's no shared session store that all your servers need to reach, which simplifies horizontal scaling and makes stateless microservice architectures much easier to reason about.
The tradeoff: once a JWT has been handed out, the server has no native way to invalidate it before its expiration time arrives. If the token gets stolen, or if a user's access needs to be cut off right away, there's nothing the server can delete, because nothing central was ever stored in the first place. This is the tradeoff that catches people off guard most often, typically after they've already architected their system around the idea that a JWT is just a faster, better version of a session.
The Misconception That Causes Real Problems
The phrase "JWTs are stateless" gets thrown around as an unqualified selling point so often that people miss what it actually implies: being stateless also means being revocation-less by default. If a session cookie gets compromised, killing it is a single deletion. A stolen JWT, on the other hand, stays fully valid and fully trusted by your server for however long its expiration window lasts, unless you've gone out of your way to build something extra to stop that.
The typical fix people reach for is maintaining a blocklist of revoked tokens:
app.use(async (req, res, next) => {
try {
const decoded = jwt.verify(req.cookies.token, process.env.JWT_SECRET);
const isRevoked = await redis.get(`revoked:${decoded.jti}`);
if (isRevoked) throw new Error("Token revoked");
req.user = decoded.userId;
} catch {
req.user = null;
}
next();
});
This does work, but look closely at what you just did: you brought back a per-request check against a shared data store, which is precisely the overhead JWTs were supposed to get rid of. At this point you don't really have a stateless system anymore. What you have is a session-based system wearing a disguise, with a messier failure mode underneath.
So Which One Should You Actually Use
Reach for sessions when: you need revocation that's immediate and dependable (think banking apps, admin dashboards, or anything where security stakes are high), you're running one application behind a load balancer that can point to a shared session store without much trouble, or you'd honestly rather maintain a single source of truth than juggle token lifetimes and blocklist logic.
Reach for JWTs when: you're handling authentication across genuinely independent services that shouldn't all need direct access to one shared session store, you're building a system where keeping tokens short-lived (minutes rather than days) makes the revocation gap acceptable, or the actual reason you want statelessness is that you're serving many independent API consumers, not just because it sounds cleaner.
One nuance worth flagging: in practice, most production systems don't pick one side outright. The pattern they converge on is short-lived JWTs combined with a refresh token that's kept server-side. It's a blend rather than a binary: sessions manage long-term trust and revocation, while JWTs cover short, stateless verification windows in between. If you find yourself treating "session versus JWT" as an either-or decision, that's usually a signal you haven't yet reached the scale where this hybrid setup earns its complexity, meaning a plain session is likely the more honest and simpler starting point.
The Actual Decision
Underneath all of this, the question was never purely technical, it's really about deciding where the cost gets absorbed. Sessions charge that cost on every single request, but in return you get control that's always current, never stale. JWTs eliminate that per-request charge, but the price is a window of time where what your server believes and what's actually true can quietly drift apart. Neither approach deserves the label of "modern" or "outdated," no matter how the conversation around them tends to get framed. They're simply two different spots to place the same tradeoff that never actually goes away.