43 // journal

JWT vs server sessions: what logout and banning actually cost

engineering · 2026-11 · 7 min read · engineering · auth · mobile

journal/jwt-vs-sessions
JWT vs server sessions: what logout and banning actually cost

fig.01 · two verification paths, and the lookup a jwt cannot do

The login screen worked on day one, the token sat in secure storage, and nobody thought about it again. Then a customer asks for "log out of all devices," or a fraud case needs an account banned mid-session, and the auth decision made in week one decides whether that is a row delete or an architecture change. The choice gets framed as JWT versus session token. The real frame is simpler: stateless verification versus a lookup you can delete.

The constraint

Authentication has two jobs: prove who the caller is, and stop proving it the moment you say so. A JWT does the first job with zero server reads, which is the entire sales pitch. The second job is where the spec quietly steps back: RFC 7519 defines exp, the time on or after which the token "MUST NOT be accepted," and jti, an identifier that "can be used to prevent the JWT from being replayed." It defines no revocation mechanism. A JWT expires on its own schedule or not at all.

The options

Opaque session token. The server generates a random ID, stores a row (token -> user, expiry) in Redis or Postgres, and every request costs one lookup. OWASP wants at least 64 bits of entropy in the ID, and does the math: at 10,000 guesses per second against 100,000 live sessions, 64 bits takes about 585 years to brute force. Revocation is DELETE. It is boring, and boring is the feature.

JWT. The server signs claims once (sub, iat, exp, maybe aud); every service verifies the signature locally and never calls home. The trade is the one above: nothing to look up means nothing to delete.

JWT plus revocation state. The hybrid everyone lands on: short-lived JWTs plus a refresh token, a jti denylist, or a token-generation counter. It works, but notice what happened: you added the server state back, in a less convenient shape.

The mechanism

The two tokens do not look alike:

SESSION
sess_9f2a41b7c3d8e0f5opaque, random, meaningless
server row: token -> { user: u_17, exp: t+86400 }
JWT
eyJhbGciOi... .eyJzdWIiOiJ1XzE3... .SflKxwRJ...
payload: { sub: u_17, iat: t, exp: t+3600, jti: 9f8e }

verify = check signature + exp. no lookup, ever.

The demo below models a user banned at t=30s holding a token minted at t=0. The token is replayed once a minute for two hours. Run it with node auth-models.mjs:

// sessions vs jwts: what banning a user actually costs
// user banned at t=30s, stolen token replayed once a minute for 2h

const TTL = 3600;            // token lifetime, a common 1h default
let t = 0;

// opaque session token
const sessions = new Map();  // token -> { sub, exp }
const issueSession = (sub) => {
  const token = `sess_${sub}_a1b2`;
  sessions.set(token, { sub, exp: t + 86400 });
  return token;
};
const readSession = (token) => {
  const row = sessions.get(token);          // one lookup per request
  return row && t < row.exp ? row.sub : null;
};

// jwt: self-contained claims, signature verification elided
const issueJwt = (sub) => ({ sub, iat: t, exp: t + TTL, jti: `jti_${sub}_9f8e` });
const verifyJwt = (jwt) => (t < jwt.exp ? jwt.sub : null);   // no lookup

// jwt + jti denylist: revocation added back as state
const denied = new Set();
const verifyJwtDeny = (jwt) =>
  t < jwt.exp && !denied.has(jwt.jti) ? jwt.sub : null;

const session = issueSession("u_17");
const jwt = issueJwt("u_17");

t = 30;
sessions.delete(session);    // logout: one row delete
denied.add(jwt.jti);         // jwt needs the row it was built to skip

const served = { session: 0, jwt: 0, denylist: 0 };
for (let m = 60; m <= 7200; m += 60) {
  t = m;
  if (readSession(session)) served.session++;
  if (verifyJwt(jwt)) served.jwt++;
  if (verifyJwtDeny(jwt)) served.denylist++;
}

console.log(`banned at t=30s; stolen token replayed once a minute for 2h`);
console.log(`served after ban: session ${served.session}, jwt ${served.jwt}, jwt+denylist ${served.denylist}`);
console.log(`server reads per request: session 1, jwt 0, jwt+denylist 1`);
console.log(`denylist must keep jti ${TTL}s, the full token lifetime`);
banned at t=30s; stolen token replayed once a minute for 2h
served after ban: session 0, jwt 59, jwt+denylist 0
server reads per request: session 1, jwt 0, jwt+denylist 1
denylist must keep jti 3600s, the full token lifetime

The session token dies the instant the row does. The JWT keeps serving for the full lifetime the issuer stamped on it: 59 requests here, and the only thing that stopped them was the clock. The denylist fixes the count and removes the benefit, one read per request, plus rows that must outlive every token they block.

Where it bites

For JWTs the bite is documented, not theoretical. AWS Cognito says it plainly: "revoked tokens will still be valid if they are verified using any JWT library that verifies the signature and expiration of the token." Firebase makes the same trade explicit: its ID tokens are stateless, so confirming a revocation costs "an extra network round trip" per check, and revoked refresh tokens leave already-issued ID tokens valid until their natural expiry. OWASP's JWT cheat sheet names the consequence: with a denylist, "user sessions won't be completely stateless anymore which might defeat the benefits of stateless sessions." In June 2026 OWASP went further and replaced the raw-token denylist example in its JWT-for-Java cheat sheet with jti-and-aud revocation, after it was shown that hashing the whole token is bypassable via signature malleability. Even the standard workaround has a footgun.

The second JWT bite is that the token is untrusted input the moment it arrives. RFC 8725 mandates pinning the algorithm (alg confusion and the none algorithm are classic account-takeover bugs), warns that kid, jku, and x5u headers are attacker-controlled input (SSRF and injection live there), and makes iss and aud validation a MUST against token-substitution attacks. None of that exists for an opaque ID, because there is nothing in it to misunderstand.

For sessions the honest cost is the lookup itself. Every service that accepts the token needs the store, or a call to the service that owns it. At one backend it is a Redis GET; at fifteen services it is a dependency graph, and when the store falls over every request fails closed. Session tokens also carry their own hygiene list: OWASP's ranges are 2-5 minutes idle for high-value apps and 15-30 for low-risk, with a 4-8 hour absolute cap in its full-day-office example. A session that never expires is a JWT with extra steps.

The numbers

  • Supabase access tokens: 3,600s default, 604,800s max; below 5 minutes is not recommended and below 2 minutes risks clock-skew rejections.
  • Cognito access/ID tokens: 1 hour default, configurable 5 minutes to 24 hours; revoked tokens still pass local signature verification.
  • Firebase ID tokens: 1 hour, revocation check requires a backend round trip.
  • OWASP session guidance: 64-bit entropy minimum; ~585-year brute force at the parameters above.
  • Denylist retention: each jti entry must live until its token's exp, so retention equals token lifetime.

Our position

Default to the opaque session token for a mobile app with one backend, which is the shape a product with a single API actually ships. Revocation is the feature you will be asked for: logout of all devices, bans, permission changes that apply now. Pay the lookup; it is a Redis GET, not a tax. Reach for JWTs when verification genuinely has to be distributed: several services checking tokens without a shared store, an edge that validates offline, or a third party that should never see your session table. In that world keep lifetimes short, rotate refresh tokens, and accept that "logged out" means "expires soon." And whichever you ship, the rule holds: if banning a user must take effect inside one request, you need server state. A JWT alone cannot give you that.

The takeaway

SESSION
opaque id + server row, one lookup per request
default when one backend owns auth

logout and ban are a row delete, instant
JWT
signed claims, verified locally, zero reads
default for distributed verifiers and third parties

cost: revocation waits for exp, minutes to an hour
DENYLIST
restores instant revocation, reintroduces the lookup
retain each jti until token expiry; denylist on

jti (+aud/iss), never the raw token (malleability)
LIFETIMES
jwt access tokens land at 5min-1h in practice
sessions: idle 2-30min, absolute 4-8h (full-day apps)
VALIDATE
pin alg, check iss + aud, treat kid/jku/x5u
as hostile input; none of this exists for opaque ids

This is the same call we make inside the API layers we build for apps like CashTaq in our product engineering work, and it pairs with the storage half of the problem in your API key is inside the APK. If "log out everywhere" sounds simple and your tokens disagree, send us the auth layer.

// next step

We work this way on client projects too.

Tell us what you are making. We reply with a plan, a timeline, and a price.

← the journal