Log a user out of a session-based app and the server forgets them on the next request. Log them out of a JWT-based app and, unless you built extra machinery for it, the token they’re holding stays valid until it expires. That difference is the real decision behind “JWT vs session”. The rest follows from it.

What a session actually stores

A session keeps the user’s state on the server and hands the browser only a lookup key.

  1. The user logs in with a password.
  2. The server creates a session record (user ID, roles, whatever it needs) and stores it in memory, a database, or Redis, keyed by a random session ID.
  3. The server sends that ID to the browser as a cookie, marked HttpOnly so client-side JavaScript can’t read it.
  4. On every request, the browser sends the cookie back automatically. The server looks up the ID in the store and gets the session data.

The cookie carries no user information, only an opaque string the server resolves on each request. Delete the record in the store and the session is gone on the next request, on every device.

What’s inside a JWT

A JSON Web Token skips the lookup by putting the state in the token itself, signed so it can’t be tampered with.

A JWT is three base64url-encoded parts joined by dots: header.payload.signature. The payload is a JSON object of claims. RFC 7519 defines a short list of registered ones:

ClaimNameMeaning
issIssuerWho issued the token
subSubjectWho the token is about
audAudienceWho the token is meant for
expExpirationReject the token after this time
nbfNot beforeReject the token before this time
iatIssued atWhen the token was created
jtiJWT IDUnique ID, useful for revocation and replay checks

The server signs the header and payload with a secret (HMAC) or a private key (RSA/ECDSA) and sends the result to the client. Any server holding the matching key, or only the public key for asymmetric signing, can verify the signature and trust the claims. No database round trip, no shared session store. In a system with several services, each one can check a token on its own.

The flip side: anyone holding the token can read the payload. Base64 is an encoding, not encryption. Never put a password, a secret, or personal data you wouldn’t print in a log into a JWT payload.

How to set up session authentication in Express

A typical Node setup uses express-session for the middleware and connect-redis so sessions survive a server restart and work across multiple instances. If Redis is new to you, how Redis caching works covers the basics of running it.

1
npm install express express-session connect-redis redis
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
const express = require("express");
const session = require("express-session");
const { RedisStore } = require("connect-redis");
const { createClient } = require("redis");

const redisClient = createClient({ url: "redis://localhost:6379" });
redisClient.connect().catch(console.error);

const app = express();
app.use(express.json());

app.use(
  session({
    store: new RedisStore({ client: redisClient, prefix: "sess:" }),
    secret: process.env.SESSION_SECRET,
    resave: false,
    saveUninitialized: false,
    cookie: {
      httpOnly: true,
      secure: true,
      sameSite: "lax",
      maxAge: 1000 * 60 * 60 * 2, // 2 hours
    },
  })
);

app.post("/login", async (req, res) => {
  const user = await authenticate(req.body.email, req.body.password);
  req.session.userId = user.id;
  res.sendStatus(204);
});

app.get("/me", (req, res) => {
  if (!req.session.userId) return res.sendStatus(401);
  res.json({ userId: req.session.userId });
});

app.post("/logout", (req, res) => {
  req.session.destroy(() => res.sendStatus(204));
});

app.listen(3000);

req.session.destroy() removes the Redis entry. The next request with that cookie finds nothing and is treated as logged out. No token blacklist, nothing to retrofit later.

How to set up JWT authentication in Node

The same flow with jsonwebtoken, issuing a short-lived access token and a separate refresh token:

1
npm install express jsonwebtoken
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
const crypto = require("crypto");
const express = require("express");
const jwt = require("jsonwebtoken");

const app = express();
app.use(express.json());

function issueTokens(user) {
  const accessToken = jwt.sign(
    { sub: user.id, role: user.role },
    process.env.JWT_ACCESS_SECRET,
    { expiresIn: "15m", algorithm: "HS256" }
  );
  const refreshToken = jwt.sign(
    { sub: user.id, jti: crypto.randomUUID() },
    process.env.JWT_REFRESH_SECRET,
    { expiresIn: "7d", algorithm: "HS256" }
  );
  return { accessToken, refreshToken };
}

function requireAuth(req, res, next) {
  const header = req.headers.authorization || "";
  const token = header.startsWith("Bearer ") ? header.slice(7) : null;
  if (!token) return res.sendStatus(401);

  try {
    req.user = jwt.verify(token, process.env.JWT_ACCESS_SECRET, {
      algorithms: ["HS256"],
    });
    next();
  } catch (err) {
    return res.sendStatus(401);
  }
}

app.post("/login", async (req, res) => {
  const user = await authenticate(req.body.email, req.body.password);
  res.json(issueTokens(user));
});

app.get("/me", requireAuth, (req, res) => {
  res.json({ userId: req.user.sub, role: req.user.role });
});

app.listen(3000);

jwt.verify checks the signature and the exp/nbf claims in one call and throws if either fails. That is the entire authentication check, with no store involved.

The algorithms: ["HS256"] option matters. Without it, a token signed with a different algorithm than you intended could still pass, which is how several JWT libraries ended up with “alg confusion” vulnerabilities. Hardcode the accepted list on verify and reject alg: "none" outright.

The refresh token is what makes a 15-minute access token livable. The client keeps it, and when the access token expires, trades it for a new pair. Store the refresh token’s jti server-side so you can revoke that one chain without touching access tokens already in flight.

Why JWTs are hard to revoke

A session is a record in a store: delete it and the user is logged out everywhere. A JWT is a self-contained claim that any verifier accepts until exp says otherwise. Logging out, banning a user, or downgrading their role does nothing to a token already issued, because the verifying server has no record of it to delete.

The usual mitigations:

  • Short access-token lifetimes. 15-30 minutes is the common range, so a stolen or stale token has a small window.
  • Refresh token rotation. Each refresh issues a new refresh token and invalidates the old one; reusing an old refresh token revokes the whole chain. OAuth 2.1 expects this (or sender-constrained tokens) for public clients.
  • A narrowly scoped denylist. To kill a specific token before it expires, key the denylist on jti plus iss, not the raw token string. The OWASP JWT Cheat Sheet explains why denylisting the string itself is unreliable. A denylist checked on every request also puts a database lookup back on every request, which is the cost JWTs were supposed to remove.

If your app ends up doing a lookup on every verification anyway, a session would have been the simpler design.

Where to store a JWT in the browser

For a browser app, put the token in an HttpOnly, Secure cookie, not localStorage. Any script running on the page can read localStorage, so one XSS hole hands over every token in it. The cost: once the token rides in a cookie, the browser sends it automatically, and you are back to defending against CSRF with SameSite and CSRF tokens, exactly as with sessions. The full comparison is in LocalStorage vs SessionStorage vs Cookies.

When to use sessions and when to use JWTs

SituationUse
One server-rendered app, one backendSessions: simpler, trivial to revoke
Instant logout or instant permission changesSessions, or JWTs with a jti denylist
Several backend services that each verify the user independentlyJWTs: no shared session store to call back to
Mobile app or third-party API clientsJWTs: bearer tokens fit a stateless HTTP API

Neither is more secure in the abstract. A Redis-backed session and a 15-minute JWT with rotated refresh tokens both hold up. The difference is architectural: how many services need to trust the claim, and how fast you need to take it back.

Building one app with one backend? Default to sessions and get revocation for free. Switch to JWTs the day a second service needs to know who the user is without calling back to the service that logged them in.