Why JWTs are controversial
JWTs solve a real problem — stateless auth across services — and then keep solving it past the point where the cure is worse than the disease. Here's where the seams are.
On this page
- The picture version
- Why it exists
- Why it matters now
- The short answer
- How it works
- Seam 1: revocation
- Seam 2: the algorithm field is attacker-controlled
- Seam 3: the claims aren’t encrypted
- Seam 4: the size
- Seam 5: clock skew, key rotation, and “small” details
- So, when should you use one?
- Check yourself
- Famous related terms
- Going deeper
The picture version
Five pictures for a reader who has never issued a token, following one small annoyance: “sign out of all devices” that takes a few minutes.
1 · The problem
Signing out used to be one deleted row.
2 · Why anyone wanted this
Put the answer in the request instead of looking it up.
3 · What is actually in one
Three parts, joined by dots.
4 · The tension it can’t resolve
You can have local verification, or instant revocation.
5 · Keep this card
The whole thing on one index card.
Why it exists
You’ve probably hit the other end of this: you click “sign out of all
devices” on some account, or change your password after a scare, and the
site tells you it may take a few minutes to take effect. A few minutes.
Signing out used to be a DELETE on one row. That delay is the visible
edge of a design decision, and this post is about the trade that produced
it.
The decision starts one layer down. Every multi-service system hits the same wall: a user logs in at the auth service, and two seconds later they hit the orders service, then the payments service, then a worker behind a queue. Each one needs to know who the user is. The naive answer — “have every service call the auth service to look up the session” — works, but now every request fans out into an extra network hop, and the auth service becomes the single point of failure for the whole fleet.
The intuition behind the JWT is to push the answer into the request itself. Instead of a session ID that the server has to look up, send a small JSON document that states who the user is and proves it with a cryptographic signature. Any service holding the right verification key (or shared secret) can verify it locally, without a network round-trip. Stateless auth.
That is genuinely useful. It’s why JWTs ended up everywhere — OIDC defines its ID token as a JWT, OAuth 2.0 access tokens are often (though not required to be) JWTs, mobile apps carry them, microservice meshes pass them on internal calls, cloud providers hand them to workloads. The controversy isn’t about the core idea. It’s about everything that gets bolted onto the core idea once people realize it’s not quite enough on its own.
Why it matters now
If you ship software in 2026, JWTs are very likely in your stack whether you chose them or not. The major identity providers — Auth0, Okta, Clerk, AWS Cognito, Firebase Auth, Supabase Auth, Google Sign-In — issue JWTs as their ID tokens, and many can issue JWT access tokens in at least some flows — though not all of them do by default, and OAuth 2.0 never required it. The same format turns up in internal service-to-service calls and in the bearer token an AI agent sends to your API. How the split actually falls across the industry isn’t something anyone publishes; the pattern is common, the proportions aren’t measured.
This shows up sharply in AI-agent systems, because agents make a lot of calls. A single user prompt can fan out into dozens of tool calls, each hitting a different service, each carrying a credential. The pressure on auth shifts from “verify a human at login” to “verify a long-running non-human caller across many services without a central bottleneck.” That’s exactly the problem JWTs are reached for, and exactly the problem the sharpest critiques say they’re a poor fit for.
Both things are true. That’s the whole controversy.
The short answer
signed JWT (JWS compact form) = base64url(header) + "." + base64url(claims JSON) + "." + base64url(signature)
Picture to keep: a festival wristband with the date printed on it instead of a guest list at the gate. Any staff member can check it at a glance without radioing anyone — and nobody can take it off you until it expires, because there is no list to cross you off.
In the form virtually everyone uses — the JWS compact form — a JWT is three base64url-encoded parts joined by dots: a header saying which algorithm signed it, a JSON payload of “claims” (who the user is, what they can do, when the token expires), and a signature over the first two parts. (RFC 7519 allows a JWT to be represented as an encrypted JWE instead; RFC 7516 is what defines that five-part form.) Any holder of the verification key can confirm the claims weren’t tampered with. The fight is over what the format encourages once people use it as a session — namely, the inability to revoke a token before it expires, plus a long history of footgun-y signature verification.
How it works
The naive story is short: sign the user’s claims once, and let every service trust them locally without asking anyone. Everything below is the list of cases that break it.
A real JWT, decoded, looks like:
header: { "alg": "RS256", "typ": "JWT", "kid": "k1" }
claims: { "sub": "user_42", "iat": 1714400000, "exp": 1714403600,
"iss": "auth.example.com", "aud": "orders" }
sig: <RSA signature over base64url(header) + "." + base64url(claims)>
A sound verifier does three things: look up the public key by kid (key
ID), recompute the signature over the header and claims, and check the
“registered claims” — exp (expiry), nbf (not before), iss (issuer),
aud (audience). The registered claims are optional in RFC 7519 itself
— it’s the deployment (OIDC, your platform, your code) that has to insist on
them. If any check fails, reject.
So far, so reasonable. The controversy lives in five seams.
Seam 1: revocation
A JWT is valid until it expires. That’s the whole design. There is no central place to “log this user out” — the token is in the wild, signed, and any service that sees it will believe it.
The usual workarounds all reintroduce the thing JWTs were supposed to remove:
- Short expiries + refresh tokens. Make the JWT live 5–15 minutes; use a separate refresh token to get a new one. Now you’ve added a second token, a refresh endpoint, and rotation logic. Compromised access tokens are still valid until they expire — you’ve capped the blast radius, not eliminated it.
- Revocation list / denylist. Every verifier checks a shared blocklist before accepting a token. This is just a session table with extra steps.
- Versioned user state. Embed a
token_versionin the JWT and check it against the database. Same — you’ve gone back to a database lookup per request.
The honest read: stateless verification and instant revocation are fundamentally in tension. JWTs picked stateless. If your threat model requires “kick this user out now,” you are going to give some of that back.
Seam 2: the algorithm field is attacker-controlled
The alg header tells the verifier which algorithm to use. Early JWT
libraries took it at face value. Two famous footguns followed:
alg: none. The spec literally defines an “unsigned” algorithm. A naïve verifier that asks “is the signature valid for the alg in the header?” will accept a token withalg: noneand an empty signature. This isn’t theoretical — multiple libraries shipped this bug. The fix is to pin the expected algorithm on the verifier side and refuse anything else.- HS256/RS256 confusion. RS256 is asymmetric (sign with private key,
verify with public). HS256 is symmetric (the same key both ways). If a
verifier accepts whichever the header says, an attacker can take the
public key (which is, by design, public), set
alg: HS256, sign a token with the public key as the secret, and the verifier will use that same public key as the HMAC secret and accept it. Same fix: pin the algorithm.
Both holes are configuration errors, not protocol breaks — but the protocol
made them easy to fall into. Tim McLean’s 2015 write-up at Auth0 (“Critical
vulnerabilities in JSON Web Token libraries”) is the canonical reference for
this class of bug. RFC 8725 (BCP 225, “JSON Web Token Best Current
Practices,” 2020) explicitly tells implementations to require an allow-list
of algorithms. In practice, modern libraries push you toward that pattern,
but you still have to configure it: node-jsonwebtoken had a related
advisory as recently as December 2022, and older code in long-lived systems
sometimes still trusts the header.
Seam 3: the claims aren’t encrypted
A JWT is signed, not encrypted. The base64 in the middle is plain JSON
that anyone holding the token can read. Putting email, role, internal_user_id,
or worse into the claims means putting them in every log, every browser
devtools tab, every error report that captures the Authorization header.
JWE — the encrypted variant — exists, and this post is about the signed form people almost always mean when they say “JWT”. Worth noticing why the encrypted one is the road less travelled: every verifier would need a decryption key, which is the thing you were trying not to distribute when you picked “verify locally with a public key”.
Seam 4: the size
A signed JWT with a handful of claims is several hundred bytes; the exact size depends heavily on the algorithm (RS256 signatures alone are 256 bytes before base64), the claim set, and whether you’re carrying scopes or group memberships. Tokens loaded with permissions grow from there, and every request carries the whole thing. Proxies, load balancers, and frameworks each cap header size at their own threshold, and a token big enough to approach those caps is a token you now have to think about.
Compare the alternative: a short opaque session ID — say 16–32 bytes of random — plus a server-side lookup. The session ID is a database round-trip; the JWT is bytes on the wire. Different costs, different places.
Seam 5: clock skew, key rotation, and “small” details
Three boring problems that bite real systems:
- Clocks drift.
expis a UNIX timestamp. If the issuer’s clock is 30 seconds ahead of the verifier’s, freshly issued tokens fail thenbfcheck. Most libraries support a leeway /clockToleranceparameter, but you generally have to opt in: PyJWT documentsleeway=0, andnode-jsonwebtokenexposesclockTolerancewithout promising you a skew budget if you leave it unset. Assume zero unless you set it, or you’ll find out in production. - Keys have to rotate. The
kidmechanism plus a JWKS endpoint handles this, but every verifier has to cache JWKS responses correctly — too aggressive and you reject tokens after rotation, not aggressive enough and you DDOS your own auth service. - Audience confusion. A token issued for service A, if not strictly
scoped, will sometimes verify against service B that shares the same
signing key. Always check
aud. Always piniss.
So, when should you use one?
The honest summary the critics and defenders mostly agree on:
- Good fit: short-lived access tokens between services in a system you control, identity assertions handed off across trust boundaries (this is what OIDC ID tokens are), workload identity in cloud environments. The pattern: stateless, short TTL, narrow audience, verifier pins the algorithm and the issuer.
- Bad fit: long-lived browser sessions you’d want to revoke instantly, carriers for sensitive data you don’t want logged, anywhere a small opaque session ID plus a Redis lookup would have done the job with less ceremony.
There’s a popular argument — most prominently Sven Slootweg’s 2016 post “Stop using JWT for sessions” — that JWTs are over-applied because the format looks neutral but actually pushes you toward a specific architecture (stateless, signed, no revocation). I think that’s a fair read. The format isn’t broken; it’s that “use a JWT” is not a substitute for thinking about your session model.
You started with `signed JWT = base64url(header) + ”.” + base64url(claims)
- ”.” + base64url(signature)
. What did this post add? —+ nothing`, and that’s exactly the problem. The format really is just those three parts; every hard question — can I revoke this, who is allowed to have signed it, what’s safe to put inside it, how long should it live — lives outside the format, in the deployment. That’s also the answer to the sign-out delay you started with: the site can’t cross you off a list, so it waits for the wristband to expire.
Check yourself
Before you go — a team switches from opaque session IDs to JWTs to remove a
Redis lookup per request. Then, to support “log out everywhere,” they add a
check of a token_version column on every request. What did they end up
with?
Answer
A session lookup with extra cryptography. Every request still hits the database, so the round-trip they were trying to remove is back; on top of it they now carry a few hundred bytes of token on every request, plus signature verification, plus key rotation and clock-skew handling. This isn’t automatically wrong — the JWT still carries a verifiable issuer and audience across trust boundaries, which a bare session ID doesn’t — but if “remove the lookup” was the only goal, the change bought nothing. The general shape: every mechanism that restores instant revocation restores the state JWTs were adopted to shed.
And one more — a verifier checks the signature, checks exp, and checks
iss. It skips aud because “all our services share the same issuer and
the same signing key anyway.” What’s the attack?
Answer
Any token any service can obtain now works at every other service.
Suppose your low-privilege analytics service is given a valid user token to
call an internal API; if the payments service accepts a correctly signed,
unexpired token from the right issuer without checking who it was
for, that same token authenticates there too. The signature was never
the missing check — it only proves the token is genuine, not that it was
genuine for you. This is why aud exists, and why “one signing key for
the whole fleet” turns every service’s tokens into every other service’s
tokens.
Famous related terms
- OAuth 2.0 —
OAuth 2.0 = framework for delegated authorization + access tokens + (usually) refresh tokens— the protocol that ended up most associated with JWTs, though OAuth 2.0 doesn’t actually require them. - OIDC —
OIDC = OAuth 2.0 + an ID token (which *is* defined to be a JWT) + a userinfo endpoint— this is where you genuinely need JWTs, because the ID token is the standardized identity assertion. - PASETO —
PASETO ≈ JWT with the footguns removed— Platform-Agnostic Security Tokens; pins one algorithm per version, noalg: none. Smaller adoption than JWT. - Macaroons —
macaroon = bearer token + attenuable caveats— a different design where the holder can narrow a token’s permissions before passing it along. Cool idea, niche adoption. - Opaque session token —
session token = random ID + server-side lookup— the boring, very effective alternative whenever a database round-trip is acceptable.
Going deeper
- RFC 7519 (JWT) and RFC 8725 (BCP 225, best current practices) — the primary sources, and the fastest way to see which of the footguns above the spec authors themselves went back and wrote warnings about.
- Sven Slootweg (joepie91), “Stop using JWT for sessions” (2016) — the explainer, and the sharpest answer to “when is an opaque session ID simply the better tool?”; read the responses alongside it.
- Tim McLean, “Critical vulnerabilities in JSON Web Token libraries” (Auth0, 2015) — the original write-up of
alg: noneand HS/RS confusion. - The OWASP JWT cheat sheet — the consolidated list of the footguns, with the standard mitigations.