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 · updated Aug 25, 2026 · 13 min read

On this page

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.

Sign out of all devices this may take a few minutes signing out used to be DELETE one row → now there is no row to delete that delay is the visible edge of a design decision
A few minutes, for an action that used to be instant. That wait is not slowness; it is the shape of the trade the rest of the post unpacks — and by the end it should be obvious why the site can only wait rather than act.

2 · Why anyone wanted this

Put the answer in the request instead of looking it up.

the naive answer: every service asks the auth service auth service orderspaymentsa worker… and more one extra hop per request and the auth service becomes the single point of failure for the whole fleet the token answer: push the answer into the request itself a small signed JSON document that states who the user is and proves it with a signature any service holding the verification key checks it locally — no round trip, no bottleneck
This part is genuinely useful, and it is why the format ended up everywhere. The controversy is not about the core idea — it is about everything bolted on once people discover the core idea isn’t quite enough on its own.

3 · What is actually in one

Three parts, joined by dots.

header which algorithm . claims who, what, until when . signature over the first two all three base64url-encoded, joined by dots what a sound verifier does 1. look up the public key by its key id, and pin the algorithm it expects rather than trusting the header 2. recompute the signature over the header and the claims 3. check expiry, issuer and audience — which the spec leaves optional, so the deployment has to insist The claims are signed, not encrypted. Any holder of the token can read them.
That last line catches people out: the middle section is plain JSON, so anything you put in it lands in every log, every devtools tab and every error report that captured the header. Signed means tamper-evident, not private.

4 · The tension it can’t resolve

You can have local verification, or instant revocation.

the tension the format cannot resolve stateless verification no lookup, no bottleneck vs instant revocation kick this user out now every workaround — short expiry plus a refresh token, a denylist, a version column checked per request — buys some of the second by giving back some of the first. The format picked stateless; that was the design. and two footguns the format made easy to reach an alg header saying “unsigned” — the spec defines it, and naive verifiers accepted it algorithm confusion — sign with the public key as a shared secret, and a verifier that trusts the header agrees Both are configuration errors. The format made them easy to fall into.
The workarounds are the tell. A denylist every verifier consults is a session table with extra steps; a version column checked per request is the database round-trip you adopted the token to avoid. Stateless verification and instant revocation are fundamentally in tension, and no amount of configuration dissolves it.

5 · Keep this card

The whole thing on one index card.

signed JWT = header . claims . signature, base64url-joined + … nothing else, and that is the problem ∴ revocation, algorithm pinning, what’s safe inside, how long it lives — all live outside the format “use a JWT” is not a substitute for thinking about your session model
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. That is also the answer to the sign-out delay: the site can’t cross you off a list, so it waits for the wristband to expire.

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:

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, 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:

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.

You started with `signed JWT = base64url(header) + ”.” + base64url(claims)

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.

Going deeper