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.
Why it exists
Every multi-service system hits the same wall. A user logs in at the auth service. 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 most of them as their access tokens too. Internal service-to-service tokens, in the systems I’ve seen, lean heavily JWT. The “bearer token” an AI agent sends to your API is often a JWT, though I don’t have a public number for the split. The pattern is clearly common; the exact proportions are guesswork.
This matters more in the AI era specifically 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 were designed 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)
In the form virtually everyone uses, 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 also defines an encrypted JWT — JWE — with five parts; it exists, it’s rarely seen.) 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
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 base64(header).base64(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. My read is that it’s rarely used in practice because the operational complexity of distributing decryption keys to every verifier defeats the original “verify locally with a public key” pitch; I don’t have a clean source quantifying that adoption gap.
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 can grow into several KB. Every request carries the whole thing. Proxies, load balancers, and frameworks each cap header size at their own threshold; the limits vary, and maximalist JWTs do hit them.
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 several common ones (PyJWT,node-jsonwebtoken) default it to zero — you have to opt in. If you don’t, you’ll find out in production. - Keys have to rotate. The
kidmechanism plus a JWKS endpoint (a URL serving the current set of public keys as JSON) 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.
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), RFC 7515 (JWS), RFC 7517 (JWK), RFC 8725 (BCP 225, JWT best current practices) — the actual specs. Short and worth reading once.
- Sven Slootweg (joepie91), “Stop using JWT for sessions” (2016) — the canonical session-side critique. The follow-up posts and the responses are all worth reading together.
- 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.