Every API has to decide, on every request, who is calling and what they're allowed to do. For most teams that means server-side sessions or JSON Web Tokens (JWTs), and the debate is full of slogans: sessions are legacy, JWTs are the only thing that scales, JWTs are insecure by design. None of them hold up in a real system.

The actual difference is where the state lives. A session keeps it on the server and hands the client an opaque reference. A JWT packs the state into the token and signs it, so any server with the right key can verify it without a lookup. That one decision drives revocation, browser storage, CSRF exposure, and how much can go wrong in validation. It also helps to separate the token format (opaque ID or JWT) from the transport (cookie or Authorization header), because you can mix and match them.

Below I cover how both work, with real Express and jose code, what "stateless" actually buys you, revocation, browser storage, common validation mistakes, and the defaults I use on new projects.

How server-side sessions work

When a user logs in, the server creates a session record (user ID, maybe roles), stores it in Redis or a database under a long random ID, and sends only that ID to the browser in a cookie. Each later request carries the cookie, and the server looks the ID up. The ID is opaque, so there's nothing to decode or tamper with, and logging someone out is a single delete. Here's my usual setup with Express, express-session, and connect-redis:

src/server.tsTypeScript
import express from "express";
import session from "express-session";
import { RedisStore } from "connect-redis";
import { createClient } from "redis";
import { verifyCredentials } from "./users.js";
 
declare module "express-session" {
  interface SessionData {
    userId: string;
  }
}
 
const sessionSecret = process.env.SESSION_SECRET;
if (!sessionSecret) throw new Error("SESSION_SECRET is not set");
 
const redisClient = createClient({ url: process.env.REDIS_URL });
redisClient.on("error", (err) => console.error("Redis error:", err));
await redisClient.connect();
 
const app = express();
app.set("trust proxy", 1); // TLS terminates at the load balancer
app.use(express.json());
 
app.use(
  session({
    store: new RedisStore({ client: redisClient, prefix: "myapp:sess:" }),
    name: "sid",
    secret: sessionSecret,
    resave: false,
    saveUninitialized: false,
    rolling: true,
    cookie: {
      httpOnly: true, // invisible to JavaScript
      secure: true, // HTTPS only
      sameSite: "lax", // withheld on cross-site subrequests and POSTs
      maxAge: 8 * 60 * 60 * 1000, // 8 idle hours; rolling resets the clock
    },
  }),
);
 
app.post("/login", async (req, res, next) => {
  try {
    const user = await verifyCredentials(req.body.email, req.body.password);
    if (!user) {
      res.status(401).json({ error: "invalid_credentials" });
      return;
    }
    // A fresh session ID at login defeats session fixation
    req.session.regenerate((err) => {
      if (err) return next(err);
      req.session.userId = user.id;
      req.session.save((saveErr) => {
        if (saveErr) return next(saveErr);
        res.sendStatus(204);
      });
    });
  } catch (err) {
    next(err);
  }
});
 
app.post("/logout", (req, res, next) => {
  req.session.destroy((err) => {
    if (err) return next(err);
    res.clearCookie("sid");
    res.sendStatus(204);
  });
});
 
app.get("/me", (req, res) => {
  if (!req.session.userId) {
    res.sendStatus(401);
    return;
  }
  res.json({ userId: req.session.userId });
});
 
app.listen(3000);

Three details here matter more than they appear to. regenerate() issues a new ID at login, which defeats session fixation (an attacker planting a known session ID in the victim's browser before they sign in). saveUninitialized: false avoids writing a Redis key for every anonymous visitor. And behind a TLS-terminating load balancer you need trust proxy, or express-session treats the request as plain HTTP and silently skips setting the Secure cookie.

Sessions do have costs: a store lookup on every request, a store on the critical path (if Redis is down, nobody is authenticated), and extra work for "log out everywhere", since sessions are keyed by session ID, not user ID. I keep a per-user Redis set of session IDs for that. The OWASP Session Management Cheat Sheet covers the rest.

How JWTs work

A JWT is a set of JSON claims, signed and serialized as three base64url segments: header.payload.signature. The header names the algorithm, the payload carries the claims, and the signature covers both, so changing a single byte invalidates it. A typical access token payload:

JSON
{
  "iss": "https://auth.example.com",
  "sub": "user_8f3a2c",
  "aud": "https://api.example.com",
  "iat": 1788307200,
  "exp": 1788308100,
  "jti": "0b6f7c1e-5d2a-4a57-9a3e-2c4f8e1d9b60",
  "scope": "orders:read orders:write"
}

iss is the issuer, sub the subject (usually a user ID), aud the intended recipient, iat and exp the issue and expiry times in Unix seconds, and jti a unique token ID. scope is a custom claim.

Terminal
node -e 'console.log(Buffer.from(process.argv[1].split(".")[1], "base64url").toString())' "$TOKEN"

HS256 vs. RS256 and EdDSA

HS256 is HMAC-SHA256 with a shared secret: the same key signs and verifies. That's fine when one service issues and checks its own tokens, but a second verifier needs the secret too, and anything that can verify an HS256 token can also mint one.

With asymmetric algorithms such as RS256 (RSA), ES256 (ECDSA on P-256), or EdDSA (Ed25519), the issuer signs with a private key and verifiers only need the public key, which is safe to publish. Issuers typically serve public keys as a JSON Web Key Set (JWKS) at a URL like /.well-known/jwks.json and put a kid (key ID) in each token's header, which makes rotation routine.

My rule: HS256 only when a single service issues and verifies, asymmetric keys the moment anyone else verifies. I prefer EdDSA or ES256, whose signatures are 64 bytes against 256 for RS256 with a 2048-bit key, and use RS256 when older libraries need to interoperate.

What stateless really buys you

A JWT lets a server authenticate a request with a local CPU operation and no network call. That's the whole benefit, and it matters when many services or regions would otherwise share one session store, or when the auth server shouldn't sit on every request's critical path. The costs:

  • Revocation. A valid signature stays valid until exp.
  • Stale claims. A demoted admin's token still says admin until it expires.
  • Size. A JWT often runs to several hundred bytes, sent with every request.
  • Complexity. Keys, rotation, algorithms, and claim validation are all new ways to get security wrong.

For most apps, a Redis lookup was never the bottleneck, and once you add refresh tokens and revocation you have server-side state again. JWTs move state off the hot path; they don't eliminate it.

The revocation problem

An access token leaks into a log file, or a laptop gets stolen, and you need that access gone now. With sessions you delete a record; with pure JWTs you wait for exp. Every mitigation either shrinks that window or brings back a lookup.

Short-lived access tokens

I keep access tokens between 5 and 15 minutes. That caps how long a leaked token is useful and how stale its claims get. It doesn't close the window, and it only works if clients can get new tokens without a fresh login, which is the job of refresh tokens.

Refresh token rotation with reuse detection

A refresh token is a long-lived credential used only at your token endpoint. I make them opaque random strings and store only a hash, so every use is a revocable lookup.

Rotation means each refresh returns a new refresh token and invalidates the old one. Reuse detection makes it worthwhile: if a used token shows up again, two parties hold copies, and since you can't tell which is the attacker, you revoke the whole token family and force a new login. RFC 9700, the OAuth 2.0 Security Best Current Practice, requires rotation or sender-constrained refresh tokens for public clients like SPAs and mobile apps.

src/auth/refresh.tsTypeScript
import { createHash, randomBytes } from "node:crypto";
import { db } from "../db.js";
import { InvalidTokenError, issueAccessToken } from "./tokens.js";
 
const sha256 = (value: string) => createHash("sha256").update(value).digest("hex");
 
export async function rotateRefreshToken(presented: string) {
  const record = await db.refreshTokens.findByHash(sha256(presented));
  if (!record || record.revokedAt || record.expiresAt.getTime() <= Date.now()) {
    throw new InvalidTokenError("refresh_token_invalid");
  }
 
  // Must be atomic: UPDATE ... SET used_at = now() WHERE id = $1 AND used_at IS NULL
  const claimed = await db.refreshTokens.markUsedIfUnused(record.id);
  if (!claimed) {
    // Already used once: assume theft and revoke the whole family
    await db.refreshTokens.revokeFamily(record.familyId);
    throw new InvalidTokenError("refresh_token_reused");
  }
 
  const refreshToken = randomBytes(32).toString("base64url");
  await db.refreshTokens.insert({
    hash: sha256(refreshToken),
    familyId: record.familyId,
    userId: record.userId,
    scopes: record.scopes,
    expiresAt: record.expiresAt, // rotation never extends the original login
  });
 
  return {
    accessToken: await issueAccessToken(record.userId, record.scopes),
    refreshToken,
  };
}

The db calls stand in for your data layer, and login inserts the first token with a new familyId. The mark-used step must be a single conditional update, or two concurrent requests can both rotate the same token. Two browser tabs refreshing at once will also look like reuse, so serialize refreshes on the client or allow a few seconds of grace.

A denylist keyed by jti

When 15 minutes is too long, say for a compromised admin account, deny individual tokens by jti, with a TTL equal to the token's remaining lifetime:

src/auth/denylist.tsTypeScript
import { redis } from "../redis.js";
 
export async function denyToken(jti: string, exp: number): Promise<void> {
  const ttlSeconds = exp - Math.floor(Date.now() / 1000);
  if (ttlSeconds > 0) {
    await redis.setEx(`denied:${jti}`, ttlSeconds, "1");
  }
}
 
export async function isDenied(jti: string): Promise<boolean> {
  return (await redis.exists(`denied:${jti}`)) === 1;
}

Check isDenied(payload.jti) after verification, and be honest about what that is: a lookup on every request, exactly what sessions do. A lighter variant is a per-user timestamp that invalidates all tokens issued before it, which covers password changes and "log out everywhere".

Issuing and verifying tokens with jose

For Node.js I use jose. It has no dependencies, runs on Node.js, Deno, Bun, and edge runtimes, and its API makes the safe path the easy one. This module issues and verifies HS256 tokens for a single service. Generate the secret with at least 256 bits of randomness:

Terminal
openssl rand -base64 32
src/auth/tokens.tsTypeScript
import { randomUUID } from "node:crypto";
import { SignJWT, errors, jwtVerify, type JWTPayload } from "jose";
 
const ISSUER = "https://auth.example.com";
const AUDIENCE = "https://api.example.com";
const ALG = "HS256";
 
const secret = process.env.JWT_SECRET;
if (!secret || secret.length < 32) {
  throw new Error("JWT_SECRET must be set to at least 32 random characters");
}
const key = new TextEncoder().encode(secret);
 
export interface AccessTokenClaims extends JWTPayload {
  sub: string;
  jti: string;
  scope: string;
}
 
export class InvalidTokenError extends Error {}
 
export async function issueAccessToken(userId: string, scopes: string[]): Promise<string> {
  return new SignJWT({ scope: scopes.join(" ") })
    .setProtectedHeader({ alg: ALG, typ: "at+jwt" })
    .setSubject(userId)
    .setIssuer(ISSUER)
    .setAudience(AUDIENCE)
    .setIssuedAt()
    .setExpirationTime("15m")
    .setJti(randomUUID())
    .sign(key);
}
 
export async function verifyAccessToken(token: string): Promise<AccessTokenClaims> {
  try {
    const { payload } = await jwtVerify<AccessTokenClaims>(token, key, {
      algorithms: [ALG], // the token's header never picks the algorithm
      issuer: ISSUER,
      audience: AUDIENCE,
      typ: "at+jwt",
      requiredClaims: ["exp", "sub", "jti"],
    });
    return payload;
  } catch (err) {
    // Expired, bad signature, wrong iss or aud, malformed: all of them mean 401
    if (err instanceof errors.JOSEError) throw new InvalidTokenError(err.code);
    throw err;
  }
}

The jwtVerify options are where the security lives. algorithms pins the algorithm, so the token's header never gets a vote. issuer and audience reject tokens minted by someone else, or by your auth server for a different API. typ: "at+jwt" follows RFC 9068 and stops other JWTs from the same issuer, like OpenID Connect ID tokens, from passing as access tokens. In middleware, answer an InvalidTokenError with a 401 and WWW-Authenticate: Bearer error="invalid_token" so clients know to get a new token.

Validation mistakes that break JWT security

Most JWT vulnerabilities are validation failures, not broken cryptography:

  • Decoding instead of verifying. Helpers like decodeJwt() skip the signature check. Use them for debugging, never for authorization.
  • Accepting alg: none. The spec defines unsecured tokens, and some libraries once accepted them when the header asked. jose never does; pinning algorithms protects hand-rolled code too.
  • Algorithm confusion. If a server expects RS256 but lets the header choose, an attacker can switch to HS256 and sign with your public key as the HMAC secret. An algorithm allowlist stops it.
  • Skipping iss, aud, or exp. Without an audience check, a token issued for one API works on every API that trusts the same issuer. Without enforced expiry, a leaked token works forever.
  • Secrets or PII in the payload. Tokens end up in logs and error trackers. Put an opaque user ID in sub, not an email address.
  • Huge tokens. Every claim rides on every request, and browsers cap a single cookie at around 4 KB. Include what authorization needs and look up the rest.

Also use a strong HMAC secret, since anyone holding one HS256 token can brute-force a weak one offline. RFC 8725, the JWT Best Current Practices, covers all of this in depth.

Storing tokens in the browser

HttpOnly cookies vs. localStorage

Anything in localStorage or sessionStorage is readable by every script on your origin, including an XSS payload or a compromised third-party script, and a stolen token can be replayed from the attacker's machine until it expires.

An HttpOnly cookie can't be read by JavaScript, Secure keeps it off plain HTTP, and SameSite controls cross-site sending. If a single-page app must hold an access token, say to call an API on another domain, I keep it in memory and put the refresh token in an HttpOnly cookie scoped to the refresh endpoint:

HTTP
Set-Cookie: rt=q8Zk3VYt...; Path=/auth/refresh; HttpOnly; Secure; SameSite=Strict; Max-Age=2592000

CSRF is the price of cookies

Because browsers attach cookies automatically, a malicious site can make a victim's browser send an authenticated request to your API: cross-site request forgery. Tokens in an Authorization header aren't exposed, since the browser never adds them on its own. The storage decision and the CSRF decision are really one decision.

Set SameSite explicitly, because browser defaults differ:

  • Lax withholds the cookie on cross-site subrequests and POSTs but sends it on top-level GET navigations, so GET handlers must never change state.
  • Strict withholds it on every cross-site request, including a user following a link from another site, who then arrives looking logged out. I reserve it for refresh cookies.

SameSite alone isn't enough: every subdomain of your registrable domain counts as same-site, including a forgotten one with an XSS hole. The OWASP CSRF Prevention Cheat Sheet treats it as defense in depth. For state-changing requests, add at least one of these:

  • A CSRF token, via the synchronizer token pattern or a signed double-submit cookie.
  • An Origin (or Sec-Fetch-Site) header check against an allowlist.
  • For JSON-only APIs, rejecting anything without Content-Type: application/json. HTML forms can't send it, and a cross-origin fetch that does triggers a CORS preflight you can refuse.

Service-to-service auth is where JWTs shine

Between backend services, the browser problems disappear: no XSS, no CSRF, no storage question. Tokens live for minutes, so revocation rarely matters, and local verification pays off when one request fans out across several services.

Each caller gets a short-lived token from an internal issuer, via the OAuth 2.0 client credentials grant or a workload identity like Kubernetes service account tokens or SPIFFE JWT-SVIDs, with aud set to the service it's calling. Receivers verify against the issuer's JWKS, which jose fetches and caches:

src/auth/service-tokens.tsTypeScript
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";
 
const ISSUER = "https://auth.internal.example.com";
 
// Fetched on first use, cached, and refetched when a token arrives with an unknown kid
const jwks = createRemoteJWKSet(new URL(`${ISSUER}/.well-known/jwks.json`));
 
export async function verifyServiceToken(token: string): Promise<JWTPayload> {
  const { payload } = await jwtVerify(token, jwks, {
    algorithms: ["EdDSA"],
    issuer: ISSUER,
    audience: "billing-service", // this service and nothing else
    requiredClaims: ["exp", "sub"],
    maxTokenAge: "5m",
  });
  return payload; // payload.sub identifies the calling service
}

Verifiers hold only public keys, so a compromised service can't mint tokens, and per-service audiences stop a token captured at one hop from being replayed at another. If you run mutual TLS, keep it: mTLS authenticates the connection, while the JWT says which service is calling and on whose behalf.

Choosing a strategy

The decision table

SituationWhat I useWhy
Web app served from your own domainServer-side sessionsInstant revocation, nothing readable by JavaScript
Web app backed by several separate APIsSession cookie at a backend-for-frontend, short-lived JWTs behind itThe browser holds only a cookie; services verify locally
Mobile or desktop appShort-lived access JWT plus rotating refresh tokenNo cookie jar; tokens go in the platform's secure storage
Public API for third-party developersOAuth 2.0 access tokens with scopesStandard delegation; JWT or opaque is an implementation detail
Service-to-service callsShort-lived JWTs, asymmetric keys, per-service audienceLocal verification, no shared secret
Instant revocation is a hard requirementSessions, or opaque tokens checked via introspectionEvery request consults current state

My default recommendation

For a first-party web app, I start with server-side sessions: an HttpOnly, Secure, SameSite=Lax cookie backed by Redis, plus CSRF tokens. It's the simplest setup that's actually secure, and revocation is one delete.

For APIs consumed by mobile apps, third parties, or other services, I use short-lived JWTs signed with asymmetric keys, plus rotating refresh tokens wherever a user is involved.

When a system has both, I run a hybrid: the browser holds a session cookie for a gateway or backend-for-frontend, which mints short-lived internal JWTs for downstream services. Users get instant logout, services get local verification, and no long-lived token touches JavaScript.

Key takeaways

  • The real difference is where state lives: sessions keep it on the server and revoke instantly; JWTs carry it in the token and stay valid until they expire.
  • "Stateless" moves lookups off the hot path. Refresh tokens and revocation bring server-side state back.
  • Keep access tokens short-lived, rotate refresh tokens with reuse detection, and hold a jti denylist in reserve.
  • Verify every JWT with pinned algorithms and required iss, aud, and exp, and never trust a payload you only decoded.
  • In browsers, prefer HttpOnly, Secure, SameSite cookies over localStorage, and pair them with CSRF protection.
  • My default: sessions for first-party web apps, short-lived asymmetric JWTs for APIs and service-to-service calls, and a hybrid when you have both.

Neither approach is the modern one. Sessions are a mature, boring answer to authenticating a browser; JWTs are a good answer to authenticating across services where a central lookup is expensive or impossible. Pick based on who your clients are and how fast you need to cut off access, and the rest of the design follows.