June 12, 2026
5 JWT Mistakes That Keep Showing Up in Production CVEs
These vulnerabilities were documented years ago. The 2026 CVE list shows they’re still shipping.
Hafiq Iqmal
7 min read
In April 2026, a security advisory landed for fast-jwt, a popular Node.js JWT library. The vulnerability was CVE-2026–34950: CVSS 9.1, algorithm confusion, attackers could bypass signature validation entirely. Fairly standard stuff by now.
Except this was the exact same attack that CVE-2023–48223 had patched in 2023. Same library. Same class of vulnerability. Same mechanism. The patch introduced a regex to detect RSA public keys before they could be misused as HMAC secrets. The regex used a ^ anchor. A string with leading whitespace defeated the anchor. The fix lasted three years before someone found a space.
That is not a story about negligence. It is a story about what JWT security actually requires: not fixing bugs when they appear, but building verification logic that cannot be defeated by the inputs you did not think of. The rest of this list follows the same pattern.
Mistake 1: Letting the Token Header Choose Its Own Algorithm
The algorithm confusion attack has been documented since 2015, when Auth0 published a writeup on critical vulnerabilities in JWT libraries. The mechanism is simple. A server signs tokens with RS256, using an RSA private key. The corresponding RSA public key is available for verification. An attacker fetches the public key, creates a token signed with HS256 using the public key as the HMAC secret, then sends that token. If the library reads the alg field from the token header and routes verification accordingly, it verifies the attacker's HS256 token against the RSA public key as an HMAC secret. The signature checks out. The attacker is in.
In 2026 this class of vulnerability produced three CVEs. CVE-2026–22817 hit Hono's JWK/JWKS middleware at CVSS 8.2. When the selected JWK did not explicitly declare an algorithm, the middleware fell back to trusting the token header's alg claim. CVE-2026-27804 and CVE-2026-23552 followed the same pattern in different libraries. Three separate codebases, three separate maintainers, same root cause: the token was allowed to influence how it was verified.
The fix is not complicated, but it requires changing how you think about the alg field. It is attacker-controlled data. Treat it accordingly.
// Vulnerable (jsonwebtoken 9.0.3)
// The library accepts whatever algorithm the token header declares
const payload = jwt.verify(token, publicKey);
// Fixed: explicitly declare the only algorithm you accept
const payload = jwt.verify(token, publicKey, { algorithms: ['RS256'] });// Vulnerable (jsonwebtoken 9.0.3)
// The library accepts whatever algorithm the token header declares
const payload = jwt.verify(token, publicKey);
// Fixed: explicitly declare the only algorithm you accept
const payload = jwt.verify(token, publicKey, { algorithms: ['RS256'] });Never let the token tell you how to verify itself. The allowed algorithms belong in your code, not in the JWT header. This is not a library default you can rely on. Check your configuration explicitly, and put that check in your code review checklist.
Mistake 2: Blocking 'none' by Name Instead of Allowlisting
The alg: none attack has been in the OWASP JWT cheat sheet for years. Most teams know about it. Most libraries have added blocklists. The problem is that blocklists require you to anticipate every variant, and JWT parsers that do case-sensitive string matching will accept nOnE, NoNE or NONE as distinct values that do not match the blocked string none. Any of those variants passes a blocklist written as an exact string comparison.
CVE-2026–39413 exposed this in LightRAG, a Python RAG framework. The API used jwt.decode() without explicit algorithm restrictions. An attacker could forge tokens by setting "alg": "none" in the header. Signature verification was skipped entirely. CVE-2026-23993 in HarbourJwt (Go) went a step further: any unrecognized algorithm value in the header bypassed signature verification completely, no need to even use none. Unknown meant accepted.
The correct fix is not to block none. The correct fix is to allowlist:
# Vulnerable (PyJWT 2.12.1)
# decode() without options trusts the header
payload = jwt.decode(token, key, options={"verify_signature": False})
# Also vulnerable: not specifying algorithms means the header is trusted
payload = jwt.decode(token, key)
# Fixed: allowlist the only algorithms you accept
payload = jwt.decode(token, key, algorithms=["RS256"])# Vulnerable (PyJWT 2.12.1)
# decode() without options trusts the header
payload = jwt.decode(token, key, options={"verify_signature": False})
# Also vulnerable: not specifying algorithms means the header is trusted
payload = jwt.decode(token, key)
# Fixed: allowlist the only algorithms you accept
payload = jwt.decode(token, key, algorithms=["RS256"])When you specify an explicit algorithm list, any token with "alg": "none" or an unrecognized value fails verification before the signature check even runs. The blocklist approach will always be one case-variant behind. Allowlisting by definition rejects everything you did not explicitly permit.
Mistake 3: Trusting Your Patch Without Auditing All Input Paths
CVE-2026–34950 deserves its own section because it represents a category of mistake harder to see than the others: the incomplete fix.
When CVE-2023–48223 was patched in fast-jwt, the fix added a regex to distinguish RSA public keys from HMAC secrets before routing verification. The regex was ^-----BEGIN (RSA )?PUBLIC KEY-----. The ^ anchor requires the match to start at the beginning of the string. If the key string has any leading whitespace (a single space, a newline character from environment variable interpolation), the regex fails to match. The library then misclassifies the RSA public key as an HMAC secret and falls back to the original vulnerable path. CVE-2026-34950, CVSS 9.1, published April 2026. The patch held for three years.
This is not a subtle cryptographic failure. It is a regex anchor. The vulnerability class is "inputs we did not consider when writing the fix."
The lesson is not to distrust your JWT library. The lesson is that patches narrow the attack surface but do not eliminate it. After any security fix, audit the fix for edge cases: what inputs does the fix assume cannot exist? Can those inputs be produced by your environment? A key loaded from an environment variable, trimmed differently across deployment environments, or provided by an external configuration service can produce leading whitespace that breaks string matching silently.
// When loading keys from environment, normalize before passing to verify
const rawKey = process.env.JWT_PUBLIC_KEY;
const publicKey = rawKey.trim();
const payload = jwt.verify(token, publicKey, { algorithms: ['RS256'] });// When loading keys from environment, normalize before passing to verify
const rawKey = process.env.JWT_PUBLIC_KEY;
const publicKey = rawKey.trim();
const payload = jwt.verify(token, publicKey, { algorithms: ['RS256'] });Normalization before the verification call would have blocked CVE-2026–34950. It costs one line. The harder ask is building a habit: whenever you ship a security patch, enumerate the assumptions embedded in the fix and write tests that violate those assumptions. Test what the patch assumed could not happen.
Mistake 4: Assuming Encryption Means Authentication
CVE-2026–29000 hit pac4j-jwt with a CVSS of 9.3. pac4j is among the top 2% most downloaded components on Maven Central. At the time of disclosure, the vulnerable versions were seeing over 30,000 downloads per week across 18 downstream packages that Sonatype identified as carrying the flaw.
The vulnerability worked like this. Applications using JWE (encrypted JWTs) for token delivery were vulnerable to an attacker who wrapped a PlainJWT (a JWT with no signature) inside a JWE envelope. The JWE outer layer decrypted successfully because the encryption key was valid. Inside was a PlainJWT with arbitrary claims, including sub (the subject, the user identity) and any role claims the attacker chose. The library, after decryption, did not verify that the inner JWT carried a valid signature. It used the claims directly.
Applications that only used signed JWTs (JWS) without encryption were not vulnerable. The attack required encryption to be configured. The JWE wrapper was exactly the mechanism that bypassed the authentication check.
This is the deepest mistake on this list because it conflicts with something that feels intuitively correct: encrypted means protected. Encryption provides confidentiality. Authentication requires a verified signature. They are separate operations, and a JWE that wraps a PlainJWT provides the first without the second. A token that cannot be read is not the same as a token that cannot be forged.
Affected versions of pac4j-jwt were all releases before 4.5.9, 5.7.9 and 6.3.3. If you are running pac4j's JWT authentication and have not updated, treat any access it has granted as potentially forged until you can verify otherwise.
The fix pac4j shipped was to reject any PlainJWT found inside a JWE envelope. For teams handling their own JWT validation logic, the architectural rule is to verify the signature of any inner token explicitly and separately from the decryption step. Decryption success does not imply authentication success. These are different questions with different answers.
Mistake 5: Building a Cache Key That Can Collide
CVE-2026–35039 in fast-jwt, CVSS 9.1, published April 2026 alongside CVE-2026–34950. This one does not involve cryptographic confusion. It involves a correctness assumption about caching.
fast-jwt optionally accepts a custom cacheKeyBuilder function. The purpose is to let developers define how tokens are indexed in the verification cache, useful for performance when the same token is verified repeatedly. If the cacheKeyBuilder function produces the same cache key for two different tokens, the cached result from the first verification is returned for the second. The second token's claims are never checked. User A's cached claims get returned to User B.
The library documentation does not prohibit this. It leaves correctness of the key builder entirely to the developer. Developers who derived the cache key from payload claims without including the signature in the key could produce collisions for tokens with identical payloads but different signing keys or user subjects.
// Vulnerable cacheKeyBuilder: uses only payload claims
// Two tokens with the same iat and sub will collide in cache
const fastJwt = createVerifier({
key: publicKey,
algorithms: ['RS256'],
cache: true,
cacheKeyBuilder: (token) => {
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
return `${payload.sub}-${payload.iat}`;
}
});
// Fixed: use the signature component as the cache key
// The signature is cryptographically unique per token; no collision is possible
const fastJwt = createVerifier({
key: publicKey,
algorithms: ['RS256'],
cache: true,
cacheKeyBuilder: (token) => token.split('.')[2]
});// Vulnerable cacheKeyBuilder: uses only payload claims
// Two tokens with the same iat and sub will collide in cache
const fastJwt = createVerifier({
key: publicKey,
algorithms: ['RS256'],
cache: true,
cacheKeyBuilder: (token) => {
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
return `${payload.sub}-${payload.iat}`;
}
});
// Fixed: use the signature component as the cache key
// The signature is cryptographically unique per token; no collision is possible
const fastJwt = createVerifier({
key: publicKey,
algorithms: ['RS256'],
cache: true,
cacheKeyBuilder: (token) => token.split('.')[2]
});Upgrade to fast-jwt 6.1.0 or later. The fix ships in that release. If you are running a custom cacheKeyBuilder on an older version, audit it now. A key derived from user-controlled payload fields without including the cryptographic signature is vulnerable to intentional collision by any user who can obtain two valid tokens.
The Pattern Behind All Five
Each mistake here lives at a different layer: cryptographic routing, algorithm parsing, patch coverage, protocol semantics, cache correctness. The surface is genuinely wide.
What they share is this: they all occur at the boundary between "the library handles JWT security" and "I need to configure the library to do that safely." The libraries are not wrong. They provide options. The mistakes happen when developers assume the defaults are safe, when patches are assumed complete without auditing their edge cases, or when abstractions like JWE encryption are assumed to carry authentication guarantees they were never designed to provide.
The 2026 CVE list for JWT libraries is not the last one.
Audit your verify() calls. Specify algorithm allowlists explicitly. Normalize key inputs before they reach the verification function. Understand whether you are using JWS, JWE or both, and what each actually guarantees. Review your cache key builder for collision risk.
None of these are hard. They are easy to skip once you assume the library covers them.