JWT is a scam and your app doesn't need it
JWT promises stateless authentication and delivers neither. It's a cargo cult that makes your app slower, less secure, and harder to maintain — and almost every developer shipping it has no idea why.
On this page
I am tired of pretending JWT is fine.
It isn't. It's a cargo cult. It solves a problem your app almost certainly does not have, it creates four or five problems your app definitely does have, and a generation of backend developers has been bullied into shipping it because some blog post in 2014 said "stateless" like it was a virtue instead of a tradeoff. Every Laravel app I started with tymondesigns/jwt-auth I eventually ripped it out of. Every JWT-based system I've audited has the same broken revocation story, the same useless refresh dance, and the same client codebase that decodes the payload and trusts it. My friend Dusan Mitrovic wrote about this in 2020. Six years later, people are still shipping the same mistakes, so here we go again.
If you're building a web app, a mobile app, or a first-party API: JWT is the wrong default and you should stop reaching for it. A row in Postgres with a bearer token in front of it is faster, simpler, and strictly more secure. The rest of this post is me showing my work.
what JWT actually is, and what the pitch was
A JWT is three base64url segments — a header, a JSON payload, a signature. The signature is either an HMAC or an RSA/ECDSA signature. The payload usually holds a user id, an iat, an exp, a jti, maybe some scopes.
The pitch is: the server signs it, the client carries it, every subsequent request only needs a signature verification — no database round-trip. Stateless authentication. That is the entire value proposition. Strip that one property away and JWT is just an opaque token wearing a costume.
So let's strip it away. It takes one question.
you cannot invalidate a JWT. you just can't.
How do you log a user out before exp?
You can't. That's the answer. The token is valid until it expires, full stop. The only way to invalidate it is to store the jti server-side in a revocation list and check that list on every single request. Which is a database lookup. Which is the thing JWT was supposed to let you skip. Congratulations, you've reinvented sessions, badly.
So you pick one of two losing moves:
- Don't invalidate. A compromised token is valid until
exp. And because nobody wants to "force users to log in again" — I have personally seen 1-year tokens, 5-year tokens, and a 10-year token in production at a company I won't name — the attacker owns the account for that entire window. Your "log out everywhere" button is a lie. Your password reset doesn't actually kick anyone out. You can rotate the signing key, sure, and log out every user on the platform to fix one breach. Beautiful design. - Maintain a revocation list. Now every request does signature verification plus a database or Redis lookup. You're paying both costs. You took on all the complexity of JWT to keep all the cost of sessions.
There is no third option. There has never been a third option. Anyone who tells you otherwise is selling something.
refresh tokens are a confession
The standard panic response to "long-lived JWTs are dangerous" is: use short-lived access tokens and a refresh token. Read that sentence carefully. It says the thing we sold you is unsafe, so here is a second thing to patch around it.
What you now ship, on every client:
- attach the access token to every request
- detect a 401 or near-expiry
- call
/auth/refreshwith the refresh token - retry the original request with the new access token
- handle the refresh failing (forced logout, redirect, queue replay)
- secure storage for two tokens instead of one
And on the server you store the refresh token in the database for revocation. So you are already stateful. You built all of this — across web, iOS, Android, and every third party that integrates with you — to avoid the thing you ended up doing anyway. Every client team now spends a sprint on token plumbing instead of shipping product. For what? Because some JS developer in 2014 thought signed JSON was elegant?
A single opaque token, looked up in Redis with Postgres as the backing store, gives you the same security in one line of middleware. No refresh. No second token. No retry loop. Nothing.
the per-request cost is real and people lie about it
The standard handwave is that signature verification is "basically free". It is not.
Rough orders of magnitude per request on a modern x86 box:
| Algorithm | Verification time | Notes |
|---|---|---|
| HS256 | ~5 µs | HMAC-SHA256, symmetric |
| RS256 | ~80–150 µs | 2048-bit RSA, what most issuers ship |
| ES256 | ~40–80 µs | NIST P-256 ECDSA |
| Redis GET | ~100–300 µs | localhost, single round-trip |
RS256 verification is in the same order of magnitude as a Redis lookup. And you still have to parse JSON, validate exp, validate iss, validate aud, walk the JWKS cache for the right key, and allocate a few objects the GC has to clean up later. Multiply by your request rate.
"JWT saves you database hits" turns out to be mostly false the moment you measure it. An opaque token check is hash, GET from Redis, done — with an in-process LRU in front if you actually care. You're not saving compute by going JWT. You are spending more of it, on something less safe, that you can't revoke.
the frontend "verification" nobody has ever shipped
The asymmetric-JWT pitch had one more leg: the server publishes a public key, the client (or a gateway, or a third party) verifies the signature locally, and the auth server stays out of the request path. If you actually do this, you do save round-trips.
Almost no one does this. Large platforms with dedicated identity teams do. The Laravel app you're shipping next month does not. WebCrypto wasn't usable when JWT became fashionable, so frontends shipped atob(token.split('.')[1]), parsed the JSON, trusted whatever was inside, and called it a day. When the access token expired the next API call returned 401, the SPA bounced to /login, and the server got hit anyway. The "stateless" benefit existed only on a slide deck.
And here's the part that should make you angry: if you hand a JWT to a third-party integrator and they skip the public-key verification — and they will, because everyone does — every single one of their requests becomes a request through your auth backend the long way around. You wear the cost of their laziness, forever. With opaque tokens this is just… how it works. No mismatch, no hidden tax, no "did they implement the checks correctly" question to lose sleep over.
encrypted JWT (JWE) is even more nonsensical
If you're encrypting the payload because it contains sensitive data, you've also decided the client can't read it. So what is it doing in the token? The client is going to call /api/me to render the UI anyway. Put the data behind that endpoint and have the token reference it. JWE solves a problem you invented by stuffing user data into a credential.
And while we're here: if the JWT payload becomes the source of truth for "who is this user", the user updates their email, their role gets revoked, their plan gets downgraded — and your app keeps reading stale values from the token until the next refresh. The user files a support ticket because the app "isn't updating". You'll fix it by fetching the user from the database on every request. You're stateful again. You were always going to be.
"just put the JWT in an httpOnly cookie"
This is the suggestion that finally gives the game away.
The advice goes: don't put the JWT in localStorage because XSS will steal it — put it in an httpOnly, Secure, SameSite cookie so JavaScript can't touch it. Read that sentence one more time. You have just described a session cookie. The browser attaches it automatically, the server reads it on every request, the client can't see what's inside.
The only thing distinguishing it from a normal session cookie is that the bytes inside happen to be a signed JWT instead of a random ID. And since the browser can't read those bytes, the "you can decode it client-side" feature — already a feature nobody used — is now physically impossible. You have kept every line of JWT complexity (signing, expiry, refresh, JWKS) and given up the only property that ever made JWT different from a session.
That's it. That's the whole house of cards. If you're putting a JWT in an httpOnly cookie, you are running a stateful session with extra cryptography. Stop. Just run the session.
"no system is stateless" — please stop pretending
Your system has users. Your system has sessions. Your system has rate limits, audit logs, permissions, billing state, devices, account lockouts, abuse signals, feature flags. Every one of those is server-side state. Reading a session row alongside the user row in the same query costs you nothing. The architectural prize of "statelessness" only matters if every other thing in your request path is also stateless — and in a real app, it isn't and it never will be.
The Okta talk "Why JWTs Are Bad for Authentication" makes this point at length, and Okta is a company that sells you JWT for a living. When the vendor tells you to stop using their flagship pattern for first-party browser apps, maybe listen.
what to ship instead
For a first-party web app:
- httpOnly, Secure, SameSite=Lax session cookie. A 256-bit random opaque ID. Done.
- Sessions table.
id,user_id,created_at,last_seen_at,user_agent,ip,revoked_at. - Redis in front keyed by the session ID hash. TTL ~60s or invalidate on write.
- Logout:
UPDATE sessions SET revoked_at = NOW() WHERE id = $1, plusDELin Redis. - Force-logout all devices: the same UPDATE with
user_id = $1. The feature your JWT app pretends to have, this app actually has.
For an API consumed by mobile or by integrators:
- Opaque bearer tokens. GitHub-style
xxx_prefix so they're greppable in logs and revocable by secret-scanning services. - Same table. Add
scopes,expires_at,last_used_at. - An admin panel to list, revoke, regenerate, expire. The thing you should have built for your JWT system but didn't.
Authorization: Bearer <token>. Document it once. Every HTTP client on Earth speaks this already.
You get everything JWT promised — issuance, revocation, expiry, scopes, audit — without the signing, the JWKS rotation, the refresh dance, the encoding gotchas, and the CVE class that begins with alg: none.
"but I'm building an OAuth 2 server"
Great. OAuth 2 is a delegation protocol — it does not require JWT. The spec says "access token" and leaves the format opaque on purpose. Build an OAuth 2 server with opaque tokens and an introspection endpoint and you are 100% compliant. The only time you actually need JWT-shaped access tokens is when your resource servers are operated by parties who genuinely cannot call your introspection endpoint on every request. That is a real use case. It is not your SaaS dashboard.
what it isn't
- It's not "JWT is broken". The cryptography is fine. The
tymondesigns/jwt-authpackage is fine. The concept of using JWT as your app's session is what's broken. - It's not blanket opposition to signed tokens. Short-lived signed JWTs for service-to-service calls inside a trusted mesh, or as ID tokens in OIDC, are legitimate. Both are narrow.
- It's not a claim opaque tokens are free. You pay for the Redis lookup. You were going to pay for it the moment you wanted to log a user out.
- It does not apply to true federation. If you're Okta, Auth0, or running an identity provider whose tokens are consumed by hundreds of resource servers you don't operate, JWT earns its keep. You are not them.
- It's not a license to hand-roll session cookies. Use your framework's session driver. The whole reason this is boring is that the framework already solved it.
If you're starting an app today, default to a session cookie or an opaque bearer token in a database, cached in Redis. Reach for JWT only when you can point at a federation requirement that actually needs it. I have built and rebuilt this stack enough times to be sure. JWT was the most expensive lesson I learned twice.
Enjoyed this post?
Here are a few ways to stay connected or work together.
Found this useful? Share it.