Skip to content
  • Authentication
  • JWT
  • Node.js
  • Express.js
  • Security
  • Web Development

JWT vs Session Authentication: How Each Works, When to Use Which (with Express Examples)

By Published 9 min read (or skim, I won't know)

Session authentication keeps the logged-in state on the server and gives the browser a cookie with a random session ID. JWT authentication puts the state inside a signed token that the client sends with every request, so the server verifies a signature instead of looking anything up. Sessions are simpler and easy to revoke; JWTs are stateless and scale across services but are hard to invalidate before they expire. Most single apps should start with sessions or short-lived JWTs in httpOnly cookies.

Why does HTTP need authentication state at all?

HTTP is stateless. The request that loads your dashboard carries no memory of the login request that came before it. So after a successful login, the server has to hand the client something it can present on every later request to prove "this is still the same logged-in user". The two mainstream answers are:

  • A session ID: a random string that means nothing by itself and is looked up on the server.
  • A JSON Web Token (JWT): a self-contained, signed record of who the user is, verified by maths rather than by lookup.

Everything else in this comparison follows from that one difference: lookup versus verify.

How does session-based authentication work?

  1. The user posts their email and password. The server checks the password hash (with bcrypt or argon2).
  2. The server creates a session record, { userId: 42, createdAt, ... }, in a session store (memory in development; Redis, PostgreSQL or MongoDB in production), keyed by a long random ID.
  3. It sends that ID to the browser in a cookie: Set-Cookie: sid=9f3c...; HttpOnly; Secure; SameSite=Lax.
  4. The browser automatically attaches the cookie to every later request to that domain.
  5. On each request, the server reads the cookie, looks up the session, and now knows the user.
  6. Logout deletes the session record. The cookie is instantly useless even if someone copied it.

The cookie contains no information, only a reference. All the state lives on the server, which is why this is called stateful authentication.

Minimal Express example with express-session

import express from "express";
import session from "express-session";

const app = express();
app.use(express.json());
app.use(
  session({
    secret: process.env.SESSION_SECRET,     // signs the cookie so it cannot be tampered with
    resave: false,
    saveUninitialized: false,
    cookie: { httpOnly: true, secure: true, sameSite: "lax", maxAge: 1000 * 60 * 60 * 24 },
    // store: new RedisStore({ client }) in production; the default MemoryStore leaks and resets on restart
  })
);

app.post("/login", async (req, res) => {
  const user = await findUserAndVerifyPassword(req.body.email, req.body.password);
  if (!user) return res.status(401).json({ error: "Invalid credentials" });
  req.session.userId = user.id;             // state lives on the server
  res.json({ ok: true });
});

const requireAuth = (req, res, next) =>
  req.session.userId ? next() : res.status(401).json({ error: "Not logged in" });

app.get("/me", requireAuth, async (req, res) => {
  res.json(await getUser(req.session.userId));
});

app.post("/logout", (req, res) => {
  req.session.destroy(() => res.json({ ok: true }));   // instant revocation
});

How does JWT authentication work?

A JWT is three base64url-encoded parts joined by dots: header.payload.signature.

PartExample (decoded)Purpose
Header{"alg":"HS256","typ":"JWT"}Which algorithm signed it
Payload{"sub":"42","role":"admin","iat":1761350400,"exp":1761351300}Claims: who, what, issued-at, expiry
SignatureHMAC-SHA256(header + "." + payload, secret)Proves the first two parts were not changed

The flow:

  1. The user logs in; the server verifies the password.
  2. The server builds the payload and signs it with a secret (HS256) or a private key (RS256). This is the token.
  3. The client stores the token and sends it on every request, either as Authorization: Bearer <token> or in a cookie.
  4. The server recomputes the signature and compares. If it matches and exp has not passed, the claims are trusted. No database lookup is needed.
  5. "Logout" means the client throws the token away. The server cannot forget a token it never stored.

The token carries the state, which is why this is called stateless authentication. It is also why a JWT is not secret: anyone holding it can base64-decode the payload and read every claim. The signature stops them changing it, not reading it.

Minimal Express example with jsonwebtoken

import express from "express";
import jwt from "jsonwebtoken";
import cookieParser from "cookie-parser";

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

const ACCESS_TTL = "15m";

app.post("/login", async (req, res) => {
  const user = await findUserAndVerifyPassword(req.body.email, req.body.password);
  if (!user) return res.status(401).json({ error: "Invalid credentials" });

  const token = jwt.sign({ sub: String(user.id), role: user.role }, process.env.JWT_SECRET, {
    algorithm: "HS256",
    expiresIn: ACCESS_TTL,
  });

  // httpOnly cookie: JavaScript on the page cannot read it, so XSS cannot steal it
  res.cookie("access", token, { httpOnly: true, secure: true, sameSite: "lax", maxAge: 15 * 60 * 1000 });
  res.json({ ok: true });
});

const requireAuth = (req, res, next) => {
  const token = req.cookies.access ?? req.headers.authorization?.replace("Bearer ", "");
  if (!token) return res.status(401).json({ error: "Not logged in" });
  try {
    req.user = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ["HS256"] }); // pin the algorithm
    next();
  } catch {
    res.status(401).json({ error: "Invalid or expired token" });
  }
};

app.get("/me", requireAuth, async (req, res) => {
  res.json(await getUser(req.user.sub));
});

app.post("/logout", (req, res) => {
  res.clearCookie("access");   // client-side only: a copied token stays valid until exp
  res.json({ ok: true });
});

This is close to what I use in Atlass CRM and HireMate: JWTs signed on login, passwords hashed with bcryptjs, and a requireAuth middleware in front of every protected route.

JWT vs session: side-by-side comparison

SessionsJWT
Where state livesServer (session store)Inside the token, on the client
Per-request costOne store lookup (fast in Redis)One signature check, no I/O
Logout / revocationInstant: delete the sessionNot possible without extra state; wait for expiry
Scaling across serversNeeds a shared store (Redis) or sticky sessionsAny server with the secret or public key can verify
Across services / domainsAwkward: every service must reach the storeNatural: pass the token along, verify anywhere
Mobile and third-party API clientsCookies are clumsyBearer header is the standard
Payload visibilityNothing on the clientClaims readable by anyone with the token
Token size per request~32 bytes (the ID)200 bytes to several KB
Changing a user's roleTakes effect immediatelyOld token keeps old role until it expires
Typical pitfallsDefault MemoryStore in production; missing cookie flagslocalStorage storage; no expiry; weak secret; alg confusion

The revocation problem, and refresh tokens

The biggest practical weakness of JWTs is that you cannot take one back. If a user logs out, changes their password, or is banned, any access token already issued keeps working until exp.

The standard answer is two tokens:

  • An access token: a JWT with a short life (5 to 15 minutes). Sent on every request. If stolen, it is useful for minutes, not weeks.
  • A refresh token: long-lived (days to weeks), stored in an httpOnly cookie and recorded in the database. Used only on a /refresh endpoint to mint a new access token.

Logout and password change now delete the refresh token row. The access token dies within minutes on its own. You get stateless verification on the hot path and real revocation on the slow path.

If a product truly needs instant invalidation of access tokens (banking, admin consoles), keep a denylist of revoked token IDs (jti claim) in Redis with a TTL equal to the token's remaining life, and check it in requireAuth. At that point you have reintroduced a lookup, and plain sessions may have been simpler.

Where to store a JWT in the browser

This is where most JWT tutorials go wrong.

  • localStorage / sessionStorage: readable by any JavaScript on the page. One XSS bug (a vulnerable dependency, a markdown renderer, a browser extension) and the token is gone. Avoid.
  • httpOnly cookie: invisible to JavaScript. Immune to token theft via XSS. Needs CSRF protection because the browser attaches cookies automatically: use SameSite=Lax or Strict, and a CSRF token or origin check for state-changing requests from other sites.
  • In memory only (a variable in your SPA): safe from XSS reads, lost on reload. Pair it with a refresh token in an httpOnly cookie so the app can silently get a new access token on page load.

Cookie flags you should always set: HttpOnly, Secure, SameSite, a Max-Age, and a tight Path if the API lives under one prefix.

Security mistakes with JWTs

  • Not pinning the algorithm. Older libraries accepted "alg": "none" or let an attacker switch RS256 to HS256 and sign with the public key. Always pass algorithms: ["HS256"] (or ["RS256"]) to verify.
  • Weak or shared secrets. HS256 secrets must be long and random (32+ bytes). If several services verify tokens, use RS256 so they only need the public key.
  • No expiry. A token without exp is a permanent password. Always set expiresIn.
  • Sensitive data in the payload. It is readable. Put the user ID and role in, not the email, address or anything regulated.
  • Trusting claims that can go stale. If role is in the token, a demoted admin stays admin until expiry. Keep sensitive authorization checks server-side when they must be instant.
  • Logging tokens. A bearer token in a log line is a credential in a log line.

Which should you use?

  • Server-rendered site or one app with one backend (most student and freelance projects): sessions with Redis, or short-lived JWTs in httpOnly cookies. Both are fine; sessions are the simpler secure default.
  • Several backend services, or a separate domain for API and frontend: JWT access tokens plus refresh tokens, verified independently by each service.
  • Mobile apps and public APIs: JWT bearer tokens.
  • Logging in with Google, GitHub, Auth0, Clerk, Firebase: the provider issues JWTs (ID tokens); your job is to verify them with the provider's public keys.

Interview questions on JWT and sessions

  1. What are the three parts of a JWT? Header (algorithm), payload (claims), signature (HMAC or RSA over the first two).
  2. Is a JWT encrypted? No. It is signed. Anyone can decode the payload; only the signature proves it was not modified.
  3. Why are JWTs called stateless? The server stores nothing per login; the token carries the state and is verified by signature.
  4. How do you revoke a JWT? You cannot directly. Use short expiry plus server-stored refresh tokens, or a denylist of token IDs.
  5. Session ID vs JWT in a cookie, what is the difference? The session ID is a lookup key; the JWT is self-describing. Both should be httpOnly, Secure and SameSite.
  6. What is CSRF and does JWT prevent it? A cross-site request that the browser sends with your cookies attached. JWT in a header is immune (nothing is attached automatically); JWT in a cookie is not, so use SameSite and CSRF tokens.
  7. What goes in sub, iat, exp? Subject (user ID), issued-at time, expiry time, all standard claims.
  8. Why not store the JWT in localStorage? Any script on the page can read it, so a single XSS vulnerability steals every user's token.

Key takeaway

Sessions: the server remembers you, the cookie is just a key, logout is instant. JWTs: the token remembers you, the server only checks a signature, and revocation needs a refresh-token layer. Pick sessions for simplicity in a single app, JWTs for multiple services and non-browser clients, and in both cases put the credential in an httpOnly, Secure, SameSite cookie with an expiry. The Node and Express fundamentals behind this, including middleware and cookies, are in the free Node.js handwritten notes; if you need authentication built properly into a MERN app, see MERN stack development services.

Frequently asked questions

What is the difference between JWT and session authentication?

With sessions, the server stores the logged-in user's state and gives the browser a cookie containing only a random session ID; each request is checked by looking that ID up. With JWT, the server signs a token that contains the user's identity and expiry, and the client sends it back on each request; the server verifies the signature instead of looking anything up. Sessions are stateful and easy to revoke; JWTs are stateless and easy to scale but hard to revoke before they expire.

Is JWT more secure than sessions?

Neither is more secure by itself. A session ID in an httpOnly, Secure, SameSite cookie is a very safe default. JWTs add risks if misused: storing them in localStorage exposes them to XSS, long expiry makes stolen tokens valuable, and the payload is readable by anyone who has the token. Used with short expiry, httpOnly cookies and a strong secret, JWTs are fine; used carelessly, they are worse than sessions.

Where should I store a JWT in the browser?

In an httpOnly cookie, not localStorage. JavaScript cannot read httpOnly cookies, so a cross-site scripting bug cannot steal the token. Add Secure so it is only sent over HTTPS, and SameSite=Lax or Strict to limit cross-site request forgery. If you must send the token in an Authorization header from a single-page app, keep it in memory only and use a short-lived access token with a refresh token in an httpOnly cookie.

How do you log out with JWT?

You cannot invalidate a signed JWT on the server without state, so logout is usually: delete the token on the client, keep access tokens short-lived (5 to 15 minutes), and revoke the refresh token in the database. If you need instant revocation of access tokens, keep a denylist of token IDs in Redis until they expire, which reintroduces some server state.

What is a refresh token?

A long-lived credential stored securely (usually an httpOnly cookie and a database record) that is used only to obtain new short-lived access tokens. It lets the access token expire quickly, limiting the damage from theft, while the user stays logged in. Because the refresh token is stored server-side, it can be revoked on logout or password change.

Can a JWT be decoded without the secret?

Yes. The header and payload are only base64url-encoded, not encrypted, so anyone with the token can read every claim. The secret is needed only to create or verify the signature. Never put passwords, card numbers or anything private in a JWT payload. If the content itself must be hidden, use an encrypted token (JWE) or keep the data on the server.

When should I use sessions instead of JWT?

Use sessions for a traditional server-rendered website or a single app with its own backend, where you want instant logout, easy revocation and the simplest secure default. Use JWTs when several services or domains must verify identity without sharing a session store, for mobile or third-party API clients, or when an identity provider issues the tokens.