Skip to main content

Command Palette

Search for a command to run...

JWT vs Sessions — The Part Nobody Tells You Until It's in Production

JWTs are stateless and fast — but you cannot revoke one. Here's what that costs in production, and when a session cookie is the better call

Updated
10 min readView as Markdown
JWT vs Sessions — The Part Nobody Tells You Until It's in Production
T
TechKis is an AI-first software & engineering studio focused on building modern digital products, scalable systems, and intelligent automation solutions. We help startups, creators, and businesses transform ideas into production-ready software using modern technologies, cloud-native architecture, and practical AI integration from day one. Unlike traditional agencies that treat AI as an afterthought, TechKis is built around the belief that AI should enhance workflows, products, and engineering decisions from the start. Our focus includes custom software development, AI-powered applications, SaaS platforms, backend engineering, automation tools, APIs, and scalable microservices architecture. We believe in clean architecture, fast execution, maintainable systems, and modern engineering practices that help businesses grow confidently. At TechKis, we combine engineering, design thinking, and AI-first development to create software that is practical, modern, and built for the future. Visit us at https://techkis.tech

Almost every auth tutorial I've read in the last five years reaches for JWT. It's the default now. And the reasons given are always the same three: it's stateless, it scales, and you don't hit the database on every request.

All three of those are true. I want to be clear about that up front, because this post is not a "JWT is insecure" post. JWT is a perfectly good, well-specified format, and the cryptography is fine.

The problem is something else entirely, and it usually doesn't surface until you're live: you can't take a JWT back.

What "stateless" actually means

A JWT carries its own claims. Your server signs a payload, hands it to the client, and from then on verification is a local operation — check the signature, check exp, trust the contents. No lookup, no shared store, no round trip.

That's genuinely elegant. It's also the exact property that causes the trouble, because "no lookup" and "I can change my mind about this token" are mutually exclusive. If the server never asks anything about the token, it has no place to record that it changed its mind.

So here's the sentence I wish tutorials led with: a signed JWT is valid until it expires, and nothing you do in between can stop that.

The concrete version

Say you issue a one-hour access token at 09:00. At 09:05 you decide that user is abusive and ban the account — flip is_active to false, feel good about it.

At 09:06 that same token still returns 200 OK on your protected endpoints. So does the one at 09:30. The token doesn't know anything happened, and neither does your verification code, because your verification code doesn't ask.

A timeline showing a JWT issued at 09:00 with a decoded payload containing role admin, the account being revoked on the server at 09:05, and the same unchanged token still returning 200 OK at 09:06 because it stays valid until its expiry

Three flavours of the same bug, all of which I've seen discussed by teams who were surprised by them:

  • Logout doesn't log out. Clearing the token client-side is the whole logout implementation in most JWT tutorials. If a copy of that token exists anywhere else — a saved request, a second device, a proxy log — it still works.

  • Bans have a delay. Whatever your access-token lifetime is, that's your worst-case window for an abusive account to keep operating.

  • Permission downgrades don't take effect. You demote someone from admin to viewer. Their existing token still says "role": "admin", and if your middleware trusts the claim (which is the entire point of putting it there), they're still an admin until expiry.

That last one is the sharpest, because it's not a security bug in JWT — it's a design consequence. Claims are a snapshot of the moment the token was signed. Anything that can change after signing shouldn't be trusted from the token alone.

Every fix reintroduces the state

This is the part I find genuinely interesting. Each standard workaround is reasonable in isolation, and each one gives back the thing JWT was chosen for.

Short expiry plus refresh tokens. Cut access tokens to 5–15 minutes and issue a long-lived refresh token. Revocation now takes effect within one access-token lifetime. But refresh tokens must be revocable to be worth anything, which means they live in a database and get looked up on refresh. You now have server-side session state — you've just renamed it and added a rotation protocol on top.

A denylist of revoked tokens. Store the jti of every revoked token and check it on each request. This works and it's correct. It's also a store lookup on every single request, which is exactly what sessions do, except your lookup key is bigger and you're carrying signature verification as well.

A token version column. My favourite of the three, and still the same trade:

// user row: { id: 42, token_version: 3 }
// JWT payload: { sub: 42, tv: 3, role: "viewer", exp: ... }

async function verify(token) {
  const claims = jwt.verify(token, PUBLIC_KEY); // signature + exp
  const user = await db.users.findById(claims.sub); // ← the lookup is back
  if (!user || user.token_version !== claims.tv) {
    throw new Error("token revoked");
  }
  return user;
}

Bump token_version and every outstanding token for that user dies immediately. It's clean, it's easy to reason about, and I'd happily ship it. But look at the second line of that function: you are querying the database on every request to validate a token whose selling point was not querying the database on every request.

That's the honest summary. There is no way to make a JWT revocable without adding server-side state, because revocation is server-side state. You can move where it lives and how often you check it, but you can't make it disappear.

What a session actually costs in 2026

The mental model a lot of people carry is that sessions mean a heavy database join on every request. They don't.

A session lookup is a single point read by primary key against a store you've already got — Redis, or a Postgres table with an index on session_id. That's a sub-millisecond class of operation on a warm local Redis, and it's on the order of a millisecond or two against Postgres in the same region. I'm not going to quote you a benchmark I didn't run, but the shape is: it's one indexed key lookup, and it's almost never what's slow about your endpoint.

A timeline showing a session id in an httpOnly cookie, the server deleting the session row at 09:05, and the very next request at 09:06 returning 401 immediately because the single indexed lookup by session id finds nothing

Compare that to the work you're doing anyway. Your handler probably fetches the user record to render anything useful, and your ORM probably issues three more queries you haven't looked at recently. Against that backdrop, the session read is noise.

"Stateless scales better" is a real argument. It is also, for the overwhelming majority of applications, a premature one. If you're running a handful of app servers behind a load balancer with one Redis, statelessness is buying you very little and costing you revocation.

Session cookie JWT
Revoke a single login Delete one row — instant Not possible without added state
Change a user's role Next request sees it Old token keeps old claims until exp
Cost per request One indexed key lookup Signature verification, no I/O
Horizontal scaling Needs a shared store Nothing shared
Verifiable by another service Only by calling you Yes, with the public key
Failure mode Store goes down, logins fail Leaked token works until expiry

Note the last row, because it cuts both ways. A shared session store is a dependency and it can go down. That's a real cost of sessions and I don't want to hand-wave it.

Where you put the token matters more than which token it is

Brief and precise, because this part attracts a lot of confident wrong advice.

For a browser app, put the credential in a cookie with all three flags set:

Set-Cookie: sid=<opaque-random-value>;
  HttpOnly;
  Secure;
  SameSite=Lax;
  Path=/;
  Max-Age=1209600

HttpOnly keeps JavaScript from reading it, which means an XSS payload can't simply exfiltrate it. Secure keeps it off plaintext connections. SameSite=Lax blocks it from most cross-site requests, which handles the common CSRF cases — though if you accept state-changing GETs or need SameSite=None for a cross-origin frontend, you still want explicit CSRF tokens.

Putting a JWT in localStorage gives up HttpOnly entirely. Any script that runs on your page — yours, a dependency's, an injected one — can read it and send it anywhere.

The standard reply is "but XSS is the real bug." That's true, and it's also not a defence — defence in depth means assuming one of your controls fails. That said, HttpOnly doesn't make you XSS-proof either: an attacker with script execution can still make authenticated requests as the user. What it stops is them walking away with a portable credential that keeps working after you've cleaned up. Meaningful, not complete.

Worth saying: a JWT can live in an httpOnly cookie. That's a fine setup. You just get the storage benefit without solving revocation.

Where JWT is genuinely the right tool

I don't want this to read as anti-JWT, because there are cases where it's clearly correct — and they all share one shape: the verifier can't or shouldn't call you.

  • Service-to-service tokens. Short-lived, narrowly scoped, minted for a specific call. Nobody needs to revoke a token that lives for 90 seconds.

  • Third-party API access. A partner validating your token with a published public key, with no path to your session store.

  • Federated identity and OIDC ID tokens. This is what the spec was designed for. An identity provider asserts something, a relying party verifies the signature. The whole model depends on offline verification.

  • Signed single-use links. Password reset, email verification, signed download URLs. Here short expiry isn't a workaround — it's the entire security property you want.

A two-column decision panel listing concrete situations for using a session cookie, such as first-party web app login and admin role changes, against situations for using a JWT, such as service-to-service calls and password reset links

The middle ground most teams land on

After all that, the setup I'd reach for by default isn't exotic:

Opaque session token in an httpOnly cookie for your own first-party web and mobile clients. Revocation is a delete. Logout works. Role changes apply immediately. One indexed lookup per request.

Short-lived JWTs only at the boundary where another service has to verify without calling you. Minutes, not days, and scoped to what that service actually needs.

That's roughly what a well-configured Laravel Sanctum setup gives you, incidentally — opaque database-backed tokens for your own app, which is why we used it on Puratan Ayurveda rather than reaching for JWT out of habit.

My personal take

I think JWT got popular for a reason that has almost nothing to do with its actual strengths: it felt like the modern choice, and sessions felt like the thing PHP did in 2009.

But statelessness isn't free. You pay for it in revocation, and revocation is not an edge case — it's logout, bans, role changes, and every incident response where step one is "cut off that session." Those are core product features, not exotic requirements.

So my rule is simple. If the thing verifying the token can call your server, use a session. If it can't, use a JWT, keep it short-lived, and be honest that anyone holding a copy is authenticated until it expires. Pick based on who's doing the verifying — not on which one sounds more scalable.

The short version

  • A signed JWT is valid until exp. Logout, bans, and role changes don't affect an already-issued token.

  • Every revocation fix — refresh tokens, denylists, token-version columns — adds back server-side state.

  • A session lookup is one indexed key read in Redis or Postgres. It's rarely your bottleneck.

  • Use HttpOnly + Secure + SameSite cookies. localStorage exposes the token to any XSS on your page.

  • JWT is the right call for service-to-service calls, third-party verification, OIDC, and signed one-off links.

  • Sensible default: opaque session cookie for your own app, short-lived JWT only where another service must verify offline.


Written by the TechKis team — an AI-first engineering studio. techkis.tech