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:
server row: token -> { user: u_17, exp: t+86400 }
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
jtientry must live until its token'sexp, 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
default when one backend owns auth
logout and ban are a row delete, instant
default for distributed verifiers and third parties
cost: revocation waits for exp, minutes to an hour
retain each jti until token expiry; denylist on
jti (+aud/iss), never the raw token (malleability)
sessions: idle 2-30min, absolute 4-8h (full-day apps)
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.
// sources
// 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.
