Christian Lehnert — Linux, Hacking & Faith

JWT for Sessions

Christian Lehnert2026-07-23~9 min read

JWT for Sessions

The Default Everyone Reaches For

At some point in the last decade, JWTs became the default answer to
"how do I keep a user logged in." A new project needs authentication,
someone reaches for a JWT library, and now the session is a signed
token the client carries around. It feels modern, it feels stateless,
it feels like the thing serious systems do.

For most of those projects, it was the wrong choice, and the right
choice was the thing JWTs were supposed to have replaced: a boring
opaque session identifier in a cookie, backed by a lookup on the
server.

This is not a contrarian take for its own sake. It is the single most
common authentication finding I write up, and it is not usually a
subtle implementation bug. It is a category error made at design time:
JWTs solve a specific problem that most applications do not have, and
in exchange they take away a property that most applications need. Let
me make the case.

What a JWT Actually Is

A JWT is three base64url-encoded parts separated by dots:
header.payload.signature. The header names the signing algorithm. The
payload is a JSON object of claims — who the user is, when the token
expires, whatever else you put in. The signature is computed over the
header and payload with a key, and it lets a verifier confirm the
token was issued by someone holding that key and has not been
tampered with.

Two facts about this shape matter more than anything else, and both
are routinely misunderstood.

The first: a signed JWT is not encrypted. The payload is
base64url, which is encoding, not encryption. Anyone holding the token
can read every claim in it by pasting it into a decoder or running one
line of code. The signature stops tampering; it does nothing for
confidentiality. Every time someone puts an email address, a role, an
internal user ID, or worse into a JWT payload thinking it is
protected, they have leaked it to anyone who ever sees the token. A
JWT is a tamper-evident postcard, not a sealed envelope.

The second: a JWT is stateless by design, which means the server
does not remember it.
This is sold as the headline feature, and it
is real. The verifier checks the signature and the expiry using only
the key, with no database lookup, so any server holding the key can
validate any token without shared session storage. That is genuinely
useful in one specific situation. It is a liability in most others,
because "the server does not remember it" is the same sentence as "the
server cannot forget it."

The Property You Gave Away: Revocation

Here is the question that decides whether JWTs are right for you: when
you need to end a session right now, can you?

A user clicks log out. An admin disables a compromised account. You
detect a stolen token and need to kill it. With a server-side session,
this is one line: delete the session record, and the next request with
that identifier fails because the lookup returns nothing. The session
is gone the instant you decide it should be.

With a stateless JWT, you cannot do this. The whole point of the token
is that the server validates it without consulting any state, which
means there is no record to delete. The token remains valid until it
expires, no matter what happens in between. The user who logged out is
still holding a token that works. The disabled account's token still
authenticates. The stolen token keeps working until its expiry, and
you get to watch.

Every real JWT deployment eventually confronts this, and the fixes all
amount to bolting statefulness back on:

Short access-token lifetimes plus refresh tokens. You make the access
token live 5 to 15 minutes, so the un-revocable window is small, and
you put revocation on the refresh-token step by refusing to issue a
new access token. This is the OAuth model and it is a reasonable
answer — but notice you have now built a stateful refresh-token store,
which is a session store with extra steps, and you still have a window
where a stolen access token works and you cannot stop it.

A jti denylist in Redis. You give each token an ID and check every
token against a Redis set of revoked IDs on every request. This works,
and it reintroduces exactly the per-request server-side lookup that
JWTs were supposed to eliminate. You are now doing a database read on
every request and carrying the complexity of JWTs. You have the
costs of both models and the central benefit of neither.

Switch to opaque tokens. Admit that you needed server-side sessions
and use a random identifier backed by a store. This is usually the
correct answer, and the fact that it is where the other two converge
is the tell.

If your answer to "can you revoke a session right now" is any of
these, you have paid the price of JWTs and rebuilt the thing they
replaced to get back the property you gave away.

The Attack Surface You Took On

Revocation is the design problem. The signature verification is the
security problem, and it is a genuinely nasty one because the JWT
format invites the vulnerability into existence.

The root flaw is that the token tells the verifier which algorithm to
use, in the alg header, and naive libraries trust it. This is the
cryptographic equivalent of a lock that asks the key what shape it
should be.

alg: none. The spec includes a "none" algorithm meaning
"unsigned." A verifier that honors the header will accept a token with
alg set to none and an empty signature, meaning an attacker can
forge any claims they like and the server validates it as authentic.
This is a decade old, it is the first thing any tester checks, and it
is still shipping in 2026 — it powers a cluster of CVEs from this year
alone.

RS256 to HS256 confusion. This is the elegant one. RS256 is
asymmetric: the server signs with a private key, verifiers check with
the public key. HS256 is symmetric: the same secret signs and
verifies. The public key is, by definition, public. So the attacker
takes a token, changes the alg header from RS256 to HS256, and signs
it using the server's public key as the HMAC secret. A verifier that
reads the algorithm from the header will now HMAC-verify the forged
token using the public key it happily hands out, and accept it. The
attacker forged a valid token using only public information.

kid injection. The kid (key ID) header tells the verifier which
key to use, and if the server uses that value to look up a key from a
file path or a database without sanitizing it, you have path traversal
or SQL injection in your authentication layer, reachable before any
signature is even checked.

The fix for all three is the same, and it is one principle:
the verifier must be told the algorithm it expects, never ask the
token.
Pin the algorithm server-side to an allowlist, reject none,
never let a code path auto-detect the algorithm from the header. Every
correct JWT deployment does this. The problem is that the insecure
version is often the shorter, more obvious code, so it is what gets
written when nobody is thinking about it. And "decoding is not
validation" — reading a token's claims is not the same as verifying
its signature, and code that confuses the two is code that trusts
forged input.

None of this attack surface exists for an opaque session identifier. A
random 32-byte token backed by a store has no algorithm to confuse, no
header to inject, no signature to forge. It is looked up or it is not.
The entire class of attacks above is simply absent.

When JWTs Are Actually Right

This is not "JWTs are bad." They are a good tool for the problem they
were built for, which is specifically stateless verification across
trust boundaries you do not want to share session state across.

The real use cases: a public API consumed by third parties who verify
tokens they cannot look up in your session store. A microservice
architecture where service A issues a token and services B, C, and D
need to verify the caller's identity without all calling back to a
central session service on every request. Single sign-on across
distinct systems. Short-lived, single-purpose tokens where the
lifetime is so short revocation is moot — a signed URL good for sixty
seconds does not need a revocation story.

The common thread is that these are cases where the statelessness is
the point — where the whole value is that a party can verify a token
without access to the issuer's storage. If you have that requirement,
JWTs earn their complexity and their sharp edges, and you pin the
algorithm and keep the lifetimes short and accept the tradeoff
knowingly.

The mistake is using them when you do not have that requirement. A
monolithic web app with one database, or a handful of app servers
behind a load balancer sharing a Redis, has no trust boundary to cross
statelessly. Both models can reach the same session store. The
stateless property buys nothing, and you paid for it with revocation
and an attack surface.

The Boring Answer

For the median web application, the authentication I would deploy is
the one that stopped being fashionable: a cryptographically random,
opaque session identifier, set in a cookie with HttpOnly, Secure,
and SameSite, backed by a session record in your database or Redis.

Log out deletes the record. Disabling an account deletes its sessions.
A stolen session is killed the moment you notice. There is no alg
header to confuse, no payload leaking claims to anyone who reads the
token, no refresh-token dance, no denylist bolted on to simulate the
revocation you would have had for free. The per-request lookup that
JWT advocates cite as the cost is a single indexed read from a store
you are already running, measured in a fraction of a millisecond, and
in exchange it gives you instant revocation and no signature attack
surface.

The session cookie is not exciting. It does not appear in conference
talks. It is the correct default for most applications, and it has
been the whole time.

The Takeaway

Before you reach for a JWT, ask one question: does any party need to
verify this token without access to my session store? If yes, JWTs
earn their place — pin the algorithm, reject none, keep lifetimes
short, put nothing secret in the payload. If no, you are about to pay
for statelessness you will not use, with revocation you will miss and
an attack surface you did not need. Use the boring session cookie.

Tagged:
#security #jwt #authentication #web-security
← Back to posts