A fintech startup shipped to production with JWT authentication. Looked solid. Used a popular library. Had middleware checking tokens on every request. Six weeks later, someone walked through their entire admin panel by changing one field in a base64-decoded token. No password needed.
The token signature wasn't being verified.
Not because the library couldn't do it. Because the developer called jwt.decode() instead of jwt.verify(). One function name. That was the whole vulnerability.
decode() vs verify() Is Not a Subtle Distinction
Every major JWT library ships both functions. decode() just reads the payload. Parses the base64, gives you the JSON. Zero cryptographic validation. And yet production code uses it for authentication all the time, because it "works" during development when you're the only user and the tokens happen to be valid anyway.
// this does NOTHING for security
const payload = jwt.decode(token);
if (payload.role === 'admin') {
// congrats, anyone can be admin now
grantAccess();
}
// this actually checks the signature
const payload = jwt.verify(token, SECRET_KEY);
// now you know the token wasn't tampered with
PyJWT, the most popular Python JWT library, had to rename their method from jwt.decode() to require an explicit algorithms parameter in v2.0 because so many people were calling it without verification. The migration broke thousands of projects. Worth it.
The Algorithm None Attack Still Works in 2026
JWTs have a header that specifies which algorithm was used to sign them. The spec includes "alg": "none" as a valid option, originally meant for situations where the token integrity is guaranteed by other means (like being inside a TLS tunnel between trusted services). In practice, it means an attacker can craft a token with alg: none, strip the signature entirely, and some libraries will accept it.
Auth0 had this bug. So did several Node.js libraries. The fix seems obvious once you know about it, but teams keep hitting it because they trust the library defaults.
// vulnerable - accepts whatever algorithm the token says
jwt.verify(token, publicKey);
// fixed - explicitly whitelist algorithms
jwt.verify(token, publicKey, { algorithms: ['RS256'] });
One line. That's the difference between "authenticated" and "anyone with a text editor can forge tokens."
Algorithm Confusion: RS256 to HS256
Worse variant. If your app uses RS256 (asymmetric, public/private key pair), an attacker can switch the algorithm to HS256 (symmetric, shared secret) and sign the token with your public key as the HMAC secret. The public key is, well, public. So now anyone can sign valid tokens.
The server sees HS256, uses the "key" it has on file (the public key), validates the HMAC, and accepts the forged token. CVE-2015-9235 documented this. Libraries patched it years ago. Custom implementations still get it wrong constantly.
Expiry Tokens That Never Expire
Setting an exp claim is step one. Actually checking it is step two. Plenty of apps set expiry during token creation and then never validate it on incoming requests. The library might check it by default. Or it might not. Depends on which library, which version, which configuration options you passed.
// creating the token with expiry - good
const token = jwt.sign(
{ userId: 123, role: 'user' },
SECRET,
{ expiresIn: '1h' }
);
// but if your verify call ignores expiry...
jwt.verify(token, SECRET, { ignoreExpiration: true });
// ...stolen tokens work forever
A penetration test on a healthcare platform found tokens with 30-day expiry. The refresh mechanism was broken, so the frontend team just cranked up the expiry to avoid user complaints about re-authentication. Thirty days of valid access from a single stolen token. For a system handling patient records.
Short-lived tokens (15 minutes or less) with proper refresh token rotation is the standard recommendation. Refresh tokens should be stored server-side, bound to the device, and revocable. Most teams implement maybe one of those three.
Where Authorization Actually Falls Apart
Authentication is "who are you." Authorization is "what can you do." Teams spend all their energy on the first part and bolt on the second as an afterthought.
Common pattern: the JWT payload has a role field. Middleware checks if the role matches the required role for the endpoint. Sounds fine. But what about horizontal authorization? User A shouldn't access User B's data, even if they have the same role. And that check almost never lives in middleware. It's scattered across individual route handlers, and someone always forgets one.
// vertical auth check - usually exists
app.get('/admin/users', requireRole('admin'), (req, res) => {
// only admins get here, cool
});
// horizontal auth check - frequently missing
app.get('/api/documents/:id', requireAuth, async (req, res) => {
const doc = await Document.findById(req.params.id);
// wait, does this document belong to req.user?
// nobody checked. IDOR vulnerability right here.
res.json(doc);
});
IDOR (Insecure Direct Object Reference) is OWASP's #1 API vulnerability for a reason. Broken Object Level Authorization, they call it now. Whatever the name, it means the same thing: you grabbed a database record by ID without confirming the requesting user owns it.
JWTs in localStorage: The Debate That Won't Die
Store JWTs in localStorage and every XSS vulnerability on your site becomes a token theft vector. JavaScript can read localStorage. Malicious JavaScript can send it to an attacker's server. Game over.
Store them in httpOnly cookies and you're dealing with CSRF instead. Pick your poison.
The actual answer depends on your threat model. SPAs with heavy API usage often go with httpOnly cookies plus CSRF tokens plus SameSite=Strict. Server-rendered apps have it easier since the cookie approach maps naturally to their request model. But "just use localStorage" keeps showing up in tutorials because it's simpler. Simpler for the developer. Not simpler for the attacker. They love it.
Refresh Token Rotation Nobody Implements Correctly
The idea: short-lived access tokens (minutes), long-lived refresh tokens (days/weeks). When the access token expires, send the refresh token to get a new pair. If a refresh token gets used twice, assume it was stolen and invalidate the entire session.
What actually ships: refresh tokens stored in the same localStorage as the access token (defeating the purpose), no rotation (same refresh token forever), no device binding, and no server-side tracking of which refresh tokens are active.
// what refresh rotation should look like
app.post('/auth/refresh', async (req, res) => {
const { refreshToken } = req.body;
const stored = await RefreshToken.findOne({ token: refreshToken });
if (!stored) {
// token doesn't exist - either expired or reuse attack
// nuke all tokens for this user to be safe
await RefreshToken.deleteMany({ userId: stored?.userId });
return res.status(401).json({ error: 'session invalidated' });
}
if (stored.used) {
// REUSE DETECTED - someone stole the token
await RefreshToken.deleteMany({ userId: stored.userId });
// maybe alert security team here
return res.status(401).json({ error: 'theft detected' });
}
// mark current token as used
stored.used = true;
await stored.save();
// issue new pair
const newAccess = generateAccessToken(stored.userId);
const newRefresh = generateRefreshToken(stored.userId);
await RefreshToken.create({
token: newRefresh,
userId: stored.userId,
used: false,
});
res.json({ accessToken: newAccess, refreshToken: newRefresh });
});
That reuse detection block? Almost nobody ships it. And it's the whole point of rotation.
Audience and Issuer Claims Get Ignored
JWTs have aud (audience) and iss (issuer) claims. They exist so a token minted for Service A can't be replayed against Service B. In microservice architectures, where multiple services share the same signing key (already questionable), skipping audience validation means a token from the user-facing API works against the internal admin service too.
Kubernetes service meshes make this worse. Services trust each other implicitly. A compromised low-privilege service gets access to tokens it can replay against high-privilege services. Istio's RequestAuthentication policy can enforce audience checks, but it's opt-in. Defaults are permissive.
The Kid Header Injection
JWTs support a kid (Key ID) header that tells the server which key to use for verification. If the server uses this value in a database query or file path lookup without sanitization, you get SQL injection or path traversal through a JWT header. Yes, really.
// if your key lookup does this
const key = await db.query(
`SELECT key_value FROM signing_keys WHERE key_id = '${header.kid}'`
);
// then an attacker sets kid to: ' UNION SELECT 'attacker-controlled-key' --
// and now they control the verification key
Niche? Sure. But it shows up in bug bounties regularly. Any value from the token header or payload that touches a database query or filesystem operation needs sanitization. The JWT is user input. All of it.
Catching Auth Flaws Before They Ship
Manual code review catches some of these patterns. But "find every place we call jwt.decode instead of jwt.verify" across a growing codebase isn't something humans do reliably at scale. Static analysis tools like Semgrep have rules for common JWT misconfigurations. CodeQL can trace data flow from token claims to database queries. But setting up custom rules for your specific auth patterns takes effort most teams skip.
ScanMyCode.dev runs automated security audits that flag authentication and authorization issues with exact file and line references. Token handling, missing authorization checks on endpoints, IDOR patterns. You get a report in 24 hours showing what's broken and how to fix it, not a 200-page PDF of theoretical risks.
What Actually Matters
Use verify(), not decode(). Whitelist algorithms explicitly. Keep access tokens short-lived. Implement refresh token rotation with reuse detection. Check authorization on every data access, not just role-based route guards. Validate aud and iss claims in multi-service architectures. Treat every field in the JWT as untrusted user input.
None of this is cutting-edge. All of it gets shipped broken regularly. The gap between knowing JWT best practices and actually implementing them correctly across every endpoint in a real application is where breaches happen.
If you haven't audited your auth layer recently, you're running on assumptions. Get a security audit and find out what's actually going on in your token handling before someone else does.