All posts
Security & Best Practices··9 min read

JWT vs Session Cookies in 2026: Stop Getting Auth Wrong

The JWT-everywhere trend caused a decade of broken auth. Here is the honest tradeoff between stateless tokens and session cookies, and what I reach for.

By

On this page

Every few months I get pulled into an incident that traces back to the same root cause: someone read a tutorial in 2017, learned that JWTs are "stateless and scalable," and built their entire web app's auth around access tokens parked in localStorage. Then a contractor shipped an XSS hole through a markdown renderer, and now an attacker has a token that stays valid for an hour with no way to kill it.

The JWT-everywhere wave did real damage. It took a tool designed for a narrow problem — service-to-service trust without a shared database — and bolted it onto interactive web apps where it is the wrong default. I want to walk through the actual tradeoff, not the hype, and tell you what I reach for in production.

What each thing actually is

A session cookie is an opaque random ID. The server generates it, stores the real session data (user ID, roles, expiry) in a server-side store keyed by that ID, and hands the browser a cookie. On every request the browser sends the cookie back, the server looks up the session, and you're authenticated. The ID itself means nothing; it's a pointer into your store.

A JWT is a signed, self-contained token. It carries the claims (user ID, roles, expiry) in its payload, and a signature proves the server issued it. The server doesn't need to store anything — it verifies the signature with a key it already holds. That's the whole pitch: no database lookup per request.

The difference that matters in practice is this: a session is revocable because the server holds the truth. A JWT is not, because the client holds the truth. Everything else flows from there.

The revocation problem is not academic

Here is the scenario that bites teams. A user clicks "log out all devices" after their laptop is stolen. With sessions, you delete the rows from your store and every stolen cookie is dead on the next request. With a stateless JWT, you can't. The token is valid until it expires because verification is just math on the signature — the server never consults a database that you could mutate.

The usual workaround is a token blocklist: keep a set of revoked token IDs and check it on every request. Stop and notice what you just did. You added a per-request database lookup to kill the one feature JWTs exist to provide. You now have all the operational cost of a session store plus the larger payload and the signing complexity. If you find yourself building a JWT blocklist, you wanted sessions.

The localStorage footgun

The second disaster is storage. Tutorials tell you to put the JWT in localStorage and attach it with an Authorization: Bearer header. The problem: localStorage is readable by any JavaScript running on your origin. One XSS bug — a vulnerable dependency, an unescaped user string in a template, a compromised npm package — and the attacker reads the token and exfiltrates it.

A cookie marked httpOnly is invisible to JavaScript. The same XSS that drains localStorage cannot read an httpOnly cookie. People counter that XSS means "game over anyway," but that's lazy. With httpOnly, the attacker is confined to acting through the victim's live browser session; they cannot lift a long-lived bearer token and replay it from their own infrastructure for the next hour. Defense in depth is the whole job.

Cookies are only safe if you set the flags correctly. Here's the shape I use in a Node/TypeScript service — note that I never hand-roll the session ID, the cookie flags, or the store contract loosely:

import { randomBytes } from "node:crypto";
import type { Response } from "express";
import { redis } from "./redis";
 
const SESSION_TTL_SECONDS = 60 * 60 * 24 * 7; // 7 days
 
export async function createSession(res: Response, userId: string) {
  // 256 bits of CSPRNG entropy. Opaque — it means nothing on its own.
  const sessionId = randomBytes(32).toString("base64url");
 
  await redis.set(
    `sess:${sessionId}`,
    JSON.stringify({ userId, createdAt: Date.now() }),
    "EX",
    SESSION_TTL_SECONDS,
  );
 
  res.cookie("sid", sessionId, {
    httpOnly: true,   // not readable by document.cookie / JS
    secure: true,     // HTTPS only; never sent over plaintext
    sameSite: "lax",  // blocks cross-site POST CSRF, keeps top-level nav working
    path: "/",
    maxAge: SESSION_TTL_SECONDS * 1000,
  });
}

Revocation is now a one-liner: await redis.del('sess:' + sessionId). Logout everywhere is a SCAN over that user's sessions. That ease is the entire point.

CSRF, because SameSite is not a complete answer

The standard objection to cookies is CSRF — a malicious site triggers a request to your API, and the browser helpfully attaches the cookie. SameSite=Lax handles most of it: cookies are withheld on cross-site POST, PUT, and DELETE, which kills the classic form-submission attack. It still permits cookies on top-level GET navigations, so keep state-changing operations off GET (you should anyway).

For anything sensitive I still layer a token. The modern, low-friction option is the double-submit pattern with a signed cookie, or for forms, an origin check. Bearer tokens in headers sidestep CSRF entirely, which is the one honest point in their favor — but you pay for it with the localStorage exposure above. The hybrid below gives you the cookie's storage safety without surrendering on CSRF.

When JWTs actually earn their place

I'm not anti-JWT. They're the right tool when statelessness is a genuine requirement and the lifetime is short:

  • Service-to-service auth. Service A calls Service B and B verifies the signature without a call back to an auth server. The token lives seconds to minutes; revocation is irrelevant at that timescale.
  • Short-lived access tokens with a refresh flow. Issue a 5-15 minute access token. Its short life is the revocation strategy — worst case the attacker has a few minutes.
  • Edge / serverless auth where the function has no connection to a central session store and the latency of a lookup per request is unacceptable.

The thread connecting all three: short lifetime. A JWT you have to revoke is a JWT you've mis-scoped.

The pragmatic hybrid

For apps that need API tokens but want safe storage, here's the pattern I deploy: a short-lived JWT access token plus a rotating refresh token, both in httpOnly cookies, with the refresh side backed by a server-side store. The access token stays stateless for the 99% of requests; the refresh endpoint is the one place you pay for a stateful lookup, and it's where revocation lives.

import { SignJWT, jwtVerify } from "jose";
import { randomBytes, timingSafeEqual } from "node:crypto";
import { redis } from "./redis";
 
const KEY = new TextEncoder().encode(process.env.JWT_SECRET!);
 
export async function issueAccessToken(userId: string) {
  return new SignJWT({ sub: userId })
    .setProtectedHeader({ alg: "HS256" })
    .setIssuedAt()
    .setExpirationTime("10m") // short life IS the revocation strategy
    .sign(KEY);
}
 
// Refresh tokens rotate on every use. Reuse of an old token = theft signal.
export async function rotateRefreshToken(presented: string) {
  const [familyId, secret] = presented.split(".");
  const stored = await redis.get(`refresh:${familyId}`);
  if (!stored) throw new Error("unknown refresh family");
 
  const ok =
    stored.length === secret.length &&
    timingSafeEqual(Buffer.from(stored), Buffer.from(secret));
 
  if (!ok) {
    // The family was already rotated — this is a replayed/stolen token.
    // Nuke the whole family so the attacker AND victim are logged out.
    await redis.del(`refresh:${familyId}`);
    throw new Error("refresh token reuse detected");
  }
 
  const next = randomBytes(32).toString("base64url");
  await redis.set(`refresh:${familyId}`, next, "EX", 60 * 60 * 24 * 30);
  return `${familyId}.${next}`;
}

Both tokens go out as cookies, never touched by JavaScript:

const cookieBase = {
  httpOnly: true,
  secure: true,
  sameSite: "strict" as const,
  path: "/",
};
 
res.cookie("access", accessJwt,    { ...cookieBase, maxAge: 10 * 60 * 1000 });
res.cookie("refresh", refreshToken, { ...cookieBase, path: "/auth/refresh", maxAge: 30 * 864e5 });

Scoping the refresh cookie to path: "/auth/refresh" means it's only ever transmitted to the one endpoint that needs it, shrinking its exposure across your request surface. Rotation with reuse-detection turns a stolen refresh token from a silent persistent compromise into an alarm that logs everyone out.

The decision, side by side

ConcernSession cookiesStateless JWTHybrid (JWT + rotating refresh)
RevocationInstant (delete the row)Impossible without a blocklistAccess expires fast; refresh revocable
Per-request store hitYesNoNo (only on refresh)
Storage safetyhttpOnly cookieFootgun if localStoragehttpOnly cookies
CSRF exposureYes — mitigate with SameSiteNone (if Bearer header)Yes — SameSite + scoped path
Payload size~40 bytes300 bytes to 1 KB+Both
Horizontal scaleNeeds shared store (Redis)TrivialRefresh needs shared store
Operational complexityLowLow until you need revocationHighest

My default and the checklist

Most interactive web apps should default to opaque session cookies backed by Redis or Postgres. They are simpler, instantly revocable, and safe by construction with the right flags. Reach for the hybrid only when you have a real mobile client or a native API consumer that needs tokens, or genuine edge-auth latency constraints. Reach for bare stateless JWTs only for short-lived service-to-service calls.

Before you ship auth, run this list:

  • Auth cookies are httpOnly, secure, and SameSite=Lax or Strict.
  • No token, session ID, or credential lives in localStorage or sessionStorage.
  • Logout actually invalidates server-side state, not just the client cookie.
  • "Log out all devices" works — if it can't, you don't have real revocation.
  • State-changing endpoints are POST/PUT/DELETE, never GET.
  • CSRF is handled (SameSite plus a token for sensitive actions).
  • If you use JWTs, access-token lifetime is minutes, not hours.
  • Refresh tokens rotate on use, with reuse detection that kills the family.
  • Session IDs come from a CSPRNG with at least 128 bits of entropy.

If you can't tick "log out all devices" honestly, you don't have an auth system — you have a trust-the-client system with extra steps. Start from sessions, add JWTs only where the math actually pays off, and you'll skip the incident I keep getting called into.