Most "should I use JWTs?" arguments online are really arguments about something else. They're about revocation, or about token size, or about cookies being scary, or about the JWT spec letting you do dangerous things. The answer is rarely "always JWT" or "always sessions" — it's "what are you actually solving, and what failure mode do you want to own?"

Both approaches are battle-tested. Both can be done badly. The difference shows up the day you need to log a user out everywhere, or rotate keys, or add a second backend service that needs to know who the caller is.

Here's how I think about it when picking for a real Node service.

Sessions: Server-Side State, Cookies On The Wire

A session is a random ID — sid=... — stored in a cookie. The server keeps the actual user data in a store (Redis, Postgres, in-memory for dev). On every request, the server looks up the session by ID.

The advantages are concrete:

  • Revocation is cheap. Delete the row. The user is logged out immediately.
  • Tokens are tiny. A 32-byte ID, not a 1KB JWT.
  • Server controls the data. Updating the user's role doesn't require a re-login.

The trade-off is that you need a store, and every authenticated request hits it. With Redis on the same network that's microseconds, but it's still a dependency.

Cookies need three settings to be safe:

TypeScript src/auth/session.ts
res.cookie('sid', sessionId, {
  httpOnly: true,           // not readable from JS — XSS can't steal it
  secure: true,             // only over HTTPS
  sameSite: 'lax',          // 'strict' for high-sensitivity, 'none' for cross-site
  maxAge: 1000 * 60 * 60 * 24 * 7,
  path: '/',
});

For Node, the modern picks are iron-session (encrypted cookies, no external store needed) and better-auth (the newer batteries-included library that took over Lucia's spot after the Lucia maintainer announced sunset in 2024 — Lucia adapters were deprecated by end of 2024 and the npm package was archived in March 2025). Express + express-session + a Redis store is the boring option that still works fine.

JWTs: Stateless Tokens, Signed Claims

A JWT is a base64-encoded payload — usually { sub, iat, exp, ...claims } — signed with a key. The server verifies the signature on each request and trusts the claims.

The advantages are also concrete:

  • No server-side lookup. Verify and go.
  • Easy to share across services. Any service with the public key can validate.
  • Works with non-cookie transports. Mobile apps, machine-to-machine, third-party APIs.

The trade-off is the one everyone underestimates: revocation is hard. A signed token is valid until it expires. If a user changes their password, the old token still works until expiry — unless you build a denylist, which is a session store with extra steps.

The standard mitigation is short-lived access tokens + refresh tokens. The access token expires in 15 minutes; the refresh token lives for days and can be revoked from a database. This buys you both speed and revocation, at the cost of two tokens to manage.

For Node, the libraries split into two camps:

  • jsonwebtoken — the classic. Synchronous, simple, has had spec-level footguns over the years (alg: none, algorithm confusion). Modern versions are safe if you pin the algorithm.
  • jose — newer, async, supports JWS/JWE/JWK out of the box. The right pick for a new service.

If JWT history makes you nervous, look at PASETO. Same idea — signed stateless tokens — but the spec deliberately removes the dangerous knobs. It's not as widespread, but it's a clean choice when you control both ends.

The Honest Comparison

Sessions JWTs
Where state lives Server (Redis/DB) Client
Revoke a user now Delete row Denylist or wait for exp
Update a role Edit row Re-login or short exp
Token size ~32 bytes 500+ bytes
Cross-service Shared store Shared key
XSS risk httpOnly cookie blocks it Bearer in JS-readable storage leaks it
CSRF risk Real, mitigate with sameSite + tokens None if not in a cookie

The pattern that wins for most web apps in 2024: session cookies for the browser, short-lived JWTs for service-to-service and mobile. You get cheap revocation where users live and stateless verification where machines live. Pick the failure mode that matches the client.

Inline diagram comparing session and JWT request flows side by side, with annotations for token storage, revocation cost, and the trust boundary that each model defends.
Two trust models, two operational profiles. Pick by revocation needs and transport.

The Library Landscape Is Actually Good Now

For a long time, Node auth meant Passport.js with twenty strategies that each behaved slightly differently. The 2024–2025 picture is friendlier:

  • better-auth — the new default in 2025. Batteries-included (passkeys, OAuth, 2FA, magic links), runs on most Node frameworks, took over from Lucia after the Lucia project was sunset in March 2025. Existing Lucia codebases either migrate or copy the patterns into their own code; the underlying primitives (Oslo, Arctic) are still maintained.
  • Auth.js (formerly NextAuth) — dominant in the Next.js world, also runs in plain Node via @auth/express.
  • iron-session — minimal, encrypted cookies, no DB needed. Great for small apps.
  • Passport.js — still around, still works, but the strategies-based plugin system is showing its age compared to the newer libs.

Avoid rolling your own from scratch unless you have a security person on the team. Hashing passwords, rotating refresh tokens, handling OAuth state, building rate limits on login — every one of these has a well-understood failure mode that a maintained library has already fixed.

Refresh Tokens: The Detail That Decides Whether JWTs Are Safe

A 24-hour JWT is not stateless auth. It's a 24-hour window where a leaked token is unstoppable. Real JWT setups use:

TypeScript src/auth/refresh.ts
import { SignJWT } from 'jose';

const accessToken = await new SignJWT({ sub: user.id, role: user.role })
  .setProtectedHeader({ alg: 'HS256' })
  .setExpirationTime('15m')
  .sign(secret);

// Refresh token is opaque, stored server-side, rotates on use
const refreshToken = crypto.randomUUID();
await db.refreshTokens.insert({ token: refreshToken, userId: user.id, expiresAt });

The refresh token lives in an httpOnly cookie. When the access token expires, the client hits /auth/refresh, the server looks up the refresh token in the DB, rotates it (issuing a new one and invalidating the old), and returns a fresh access token. Now you have stateless verification on hot paths and a real revocation story.

If you skip rotation and just let the same refresh token live forever, you've reinvented the long-lived-session problem with extra steps.

Where People Actually Get Hurt

Three failure patterns I keep seeing:

  1. JWTs in localStorage. Any XSS reads the token and exfiltrates it. If you must use JWTs in the browser, use httpOnly cookies — at which point you also have CSRF to think about, and you're back to needing a sameSite or CSRF-token strategy.
  2. No exp on JWTs. A token without an expiration is a permanent credential. Always set exp. Always.
  3. Trusting JWT claims for authorization. A claim like { role: 'admin' } is true at issue time. If you demote the user, the old token still says admin until it expires. Authorize against the database for sensitive operations, not against stale claims.

A One-Sentence Mental Model

Sessions trade a lookup for cheap revocation; JWTs trade revocation for cheap verification — pick the one whose worst-case failure mode you can live with, and add refresh tokens the moment you reach for JWT 👊