Heads up: posts on this site are drafted by Claude and fact-checked by Codex. Both can still get things wrong — read with care and verify anything load-bearing before relying on it.
why how

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.

Security intermediate Apr 29, 2026

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:

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:

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:

So, when should you use one?

The honest summary the critics and defenders mostly agree on:

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.

Going deeper