JWTs Are Overrated
JWTs are powerful and widely adopted — but they're massively overused. They're an excellent tool for one specific job: one-time cross-service authentication. Using them as general-purpose session tokens is where developers go wrong.
The Real-World Cost: The Linus Tech Tips Hack (2023)
March 2023 — Linus Media Group
An employee unknowingly opened a malicious .exe. It silently copied active browser session tokens from their machine — including all active sessions for their YouTube accounts — and sent them to the attacker's computer.
The attacker used those stolen tokens to hijack three major YouTube channels and run a crypto scam. But the most damaging part wasn't the initial breach. It was this:
Even after regaining control of the accounts, there was no way to immediately remove the hacker's access.
The session tokens were still valid. There was no kill switch.
"I'm fairly sure this particular issue wasn't caused by JWTs, but it is a great example of the kind of vulnerability you open yourself up to when using them as a session token."
This is the revocation problem made concrete. When your tokens can't be invalidated, a breach becomes a siege.
Stateful vs Stateless Tokens — The Core Distinction
Stateful token (opaque session):
Token → just a random ID
Data → stored on the SERVER
Server can: revoke instantly ✅
update permissions ✅
see all active sessions ✅
Stateless token (JWT):
Token → contains the data itself, signed
Data → stored IN the TOKEN
Server can: verify the signature ✅
NOT revoke (no record of it) ❌
NOT update permissions after issuance ❌
"This adjective is referring to the service state, not the token." Stateless means the server holds no state about you — not that the token has no state.
What JWT Actually Is
A JWT has three Base64url-encoded parts separated by dots:
[Header].[Payload].[Signature]
Header — metadata, crucially the signing algorithm:
{ "alg": "HS256", "typ": "JWT" }
Payload — the actual data (subject, issuer, expiry, custom fields):
{
"sub": "user_42",
"role": "admin",
"exp": 1704067200,
"iat": 1704063600
}
Signature — computed by signing header + payload with the chosen algorithm:
sign(base64(header) + "." + base64(payload), key)
The signature means any service can verify the token without calling back to the issuer — this is the portability that makes JWTs appealing, and also the source of their problems.
The Three Criticisms of JWTs
mindmap
root((JWT Criticisms))
1 Library Implementation Bugs
alg none bypass
Algorithm confusion RS256 to HS256
Moderate validity
Bugs happen in all crypto
Libraries get patched
Whitelist algorithms explicitly
2 Overkill and Overuse
More valid
Statelessness is convenient
So we reach for JWT by default
But simple session state solves most problems
JWT is complex machinery for a simple job
When used for cross-service auth appropriate complexity
When used for sessions massive and clunky
3 Cannot Revoke
Most valid by far
Fundamental design property not a bug
Token issued permissions locked in
Cannot be changed until expiry
Cannot be invalidated if stolen
The Linus hack is the consequence
Criticism 1 — Library Bugs (Moderate Validity)
The most notorious exploit: passing "alg": "none" in the header caused some libraries to skip signature verification entirely. Others were vulnerable to algorithm confusion — switching RS256 to HS256 to forge tokens.
Attack: change header to { "alg": "none" }
→ some libraries: "no algorithm = no signature check" → accepts any payload ❌
Fix: always explicitly whitelist accepted algorithms
# Python
jwt.decode(token, secret, algorithms=["HS256"])
# Node.js
jwt.verify(token, secret, { algorithms: ["HS256"] })
Cryptography is hard. Mistakes happen in every standard. What matters is that they get fixed and best practices get updated. This criticism is somewhat overstated — it's an argument to use good libraries and pin algorithms, not an argument against JWTs.
Criticism 2 — Overkill (More Valid)
"Statelessness is very convenient, leading many to choose a complex token mechanism over a simple state solution."
When used for their intended purpose — cross-service authentication — JWTs are about as complex as they need to be. When used as session tokens, they're massive and clunky. The problem isn't the tool; it's reaching for it habitually.
Criticism 3 — Cannot Revoke (Most Valid)
"This issue is a smell that something is wrong with the way JWTs are used and that they aren't suitable as session tokens."
Opaque token revocation: DELETE FROM sessions WHERE token = 'x' → instant ✅
JWT revocation: ??? the server has no record of it → impossible ❌
When a JWT is issued, the expiration date and permissions are locked in.
They cannot be changed. The token is client-authoritative.
This isn't a bug. It's the design. And it means:
- A fired employee's token is still valid until it expires
- A stolen token cannot be killed remotely
- A security breach cannot be contained by revoking sessions
"The existence of this vulnerability at all is a clear indication that stateless tokens are not well suited for managing sessions."
The only fix — a server-side blacklist of revoked JWTs — immediately defeats the statelessness advantage. At that point, you're managing server state for tokens anyway. You may as well use a stateful session and get all the benefits.
When to Actually Use JWTs
The argument isn't "never use JWTs." It's match the tool to the job.
Case 1 — Single Server, Single Client → Don't Use JWT
Use an opaque session token instead.
✅ Smaller (just a random string, not a full signed document)
✅ Instantly revocable
✅ Permissions updateable server-side at any time
✅ Simpler implementation
✅ No signing, no algorithm selection, no library footguns
"In almost all cases there is no need to store user data on the token.
You can make use of caching to fetch user data efficiently."
The performance argument for JWTs often doesn't hold up:
Claim: "JWTs avoid DB calls"
Reality: Most endpoints need fresh user data anyway → DB call happens regardless
JWT payload becomes stale the moment it's issued
Role changed? Subscription cancelled? Too bad — JWT says otherwise until it expires.
Case 2 — Cross-Service Auth (Services You Own) → Symmetric JWT, Once Only
Client has session with Service A.
Client needs access to Service B.
1. Service A issues a signed JWT: "this client may talk to Service B" (HS256, short TTL)
2. Client makes ONE request to Service B with that JWT
3. Service B verifies → creates its OWN opaque session for the client
4. The original JWT is rejected for any subsequent request
Key: the JWT is a handshake, not a session. Used once. Discarded.
Why symmetric (HS256) here:
You own all the services → shared secret is acceptable
Simpler and faster than asymmetric crypto
No need to publish a public key
Case 3 — Cross-Service Auth (External Service) → Asymmetric JWT, Once Only
Service A (external) issues a JWT signed with its private key.
Service B (yours) verifies with Service A's public key.
✅ Service A's private key never leaves their system
✅ Service B only needs the public key (safe to share openly)
✅ No shared secret to leak
Same rule: accept the token ONCE, then create your own session.
The One-Time Token Pattern
This is the key insight from this perspective. Stateless tokens should really only be used once.
❌ Wrong pattern (JWT as ongoing session):
Login → get JWT → use JWT for every request forever (until expiry)
Stolen JWT → attacker has full access until expiry. No kill switch.
✅ Right pattern (JWT as bootstrap handshake):
Step 1: Client authenticates → receives short-lived JWT
Step 2: Client presents JWT to target service ONCE
Step 3: Target service verifies → creates opaque session for client
Step 4: Client uses opaque session for all ongoing requests
Step 5: JWT is invalidated / no longer accepted
Result: JWT's portability used for its strength (no shared state needed for auth)
Opaque session used for its strength (revocable, updateable)
PASETO — A Cleaner Alternative
PASETO (Platform-Agnostic Security Tokens) addresses JWT's footguns at the spec level.
JWT problem → PASETO fix:
Algorithm agility (attacker picks alg)
→ PASETO: algorithm is baked into the version. Not configurable by the token.
Payload is readable (only Base64)
→ PASETO local: payload is AES-256 ENCRYPTED. Unreadable without the key.
Complex, error-prone spec
→ PASETO: simpler, fewer valid configurations, harder to misuse
| JWT | PASETO | |
|---|---|---|
| Payload | Base64 (readable) | Encrypted (local) or Signed (public) |
| Algorithm selection | In the header (attacker-controllable) | Fixed per version |
alg:none attack |
Possible in naive implementations | Not possible by design |
| Adoption | Ubiquitous | Growing, not yet universal |
PASETO versions:
v3.local → symmetric encryption (like HS256, but payload is encrypted)
v4.public → asymmetric signing (like RS256, but cleaner spec)
Use PASETO if: starting a new project, want fewer footguns, payload privacy matters
Use JWT if: ecosystem compatibility required (most IdPs, libraries, tooling speak JWT)
The Decision Rules — Simplified
Ongoing server-client session?
→ Opaque session token. Always. No debate.
One-time cross-service handshake (services you own)?
→ HS256 JWT. Short TTL. Accept once. Create your own session after.
One-time cross-service handshake (external service)?
→ RS256 JWT or PASETO public. Verify once. Create your own session after.
Absolute requirement for ongoing stateless sessions?
→ JWT with short expiry + refresh token rotation.
Know what you're trading: stolen token = valid until expiry. No kill switch.
Summary
JWTs are not bad. They're misused.
Their superpower: cryptographically verifiable, portable, no shared state between services
Their kryptonite: cannot be revoked, permissions locked at issuance, client-authoritative
The right mental model:
JWT = a signed letter of introduction between services
Present it once to establish trust. Then use a proper session.
NOT: JWT = a long-lived badge you wear everywhere
Default to stateful opaque sessions.
Reach for JWTs when you genuinely need portable cross-service authentication.
Use them once. Then discard them.