Safeguard
Application Security

JWT security vulnerabilities and best practices

The jsonwebtoken library shipped three separate signature-bypass CVEs between 2015 and 2022 — algorithm confusion is still the most common way JWTs fail.

Safeguard Research Team
Research
6 min read

JSON Web Tokens carry the weight of authentication for a huge share of modern APIs, and their track record of implementation bugs is long enough to have its own CVE lineage. CVE-2015-9235 showed that the popular Node.js jsonwebtoken package, at versions 4.2.1 and earlier, could be tricked into accepting an HMAC-signed token as if it were RSA-verified — an attacker who knew a server's RSA public key could resign a token with HS256 using that public key as the shared secret, and the library would call it valid. Seven years later, the same package needed two more advisories in the same release: CVE-2022-23540 covered a fallback to the unsigned none algorithm when verify() was called with a falsy key, and CVE-2022-23541 covered insecure key-retrieval callbacks that let forged tokens through. Both were finally closed in jsonwebtoken v9.0.0, published in December 2022. None of these are exotic bugs — they are all variations on the same root cause: trusting the algorithm the token claims to use instead of the algorithm the server expects. This post walks through the specific failure modes and the configuration choices that close them.

What is algorithm confusion, and why did it break jsonwebtoken twice?

Algorithm confusion happens when a server configured to verify asymmetric signatures (RS256, ES256) can be coerced into treating an attacker-supplied token as an HMAC (HS256) token instead, using the server's own public key as the HMAC secret. Because RSA public keys are, by design, not secret, an attacker who obtains one — often trivial, since it may be published at a JWKS endpoint — can sign a forged token with HS256 using that key as the shared secret. If the verifying code passes the algorithm from the token header into the verification routine rather than pinning it server-side, the forged signature checks out. This is exactly what CVE-2015-9235 exploited in jsonwebtoken ≤4.2.1, and CVE-2022-23541 reintroduced a variant of it through unsafe key-lookup callbacks that weren't algorithm-aware. The fix in both cases was the same: verify() calls must pass an explicit algorithms allowlist (for example { algorithms: ["RS256"] } ) so the library rejects any token whose header claims a different algorithm, no matter what the payload contains.

How does the "alg: none" attack bypass signature checks entirely?

The alg: none attack works because the JWT header is just base64-encoded, attacker-controlled JSON, and some libraries and hand-rolled parsers historically honored a header claiming {"alg":"none"} by skipping signature verification altogether. An attacker takes any valid token, rewrites the header to declare none, strips the signature segment, and resubmits it — if the verifier doesn't explicitly reject unsigned tokens, it accepts a token it never actually authenticated. This pattern has been documented and exploited since roughly 2015, and it's exactly what CVE-2022-23540 reopened in jsonwebtoken ≤8.5.1: calling jwt.verify() with a falsy secret and no algorithms option let a request fall through to none-algorithm acceptance. The durable fix, per the OWASP JWT Cheat Sheet, is to never let the token's header decide the verification algorithm — the server-side configuration decides, full stop, and none should never appear in an allowlist that guards anything real.

Why do JWTs sometimes never expire, even when exp looks correctly set?

JWTs sometimes never expire in practice because RFC 7519 defines the exp (expiration), nbf (not-before), and iat (issued-at) claims as OPTIONAL, which means a library can decode and "validate" a token's signature successfully while never checking whether the token itself is temporally still valid. A token can carry a perfectly formatted exp timestamp in its payload and still be accepted indefinitely if the application code that calls the verify function doesn't also enforce that claim — some minimal JWT parsers only check the signature, leaving expiry enforcement entirely up to the calling application. This is a common root cause behind "why did this session token from six months ago still work" incidents. The fix is to use a verification function (not just a decode function) from a maintained library, confirm in its documentation that exp and nbf are enforced by default, and never build custom decode-only logic that skips straight to reading claims out of the payload without a verify step.

What makes a JWT secret or signing key genuinely secure?

A JWT signing secret is only as strong as its entropy and its exposure surface, and both are common weak points in production systems. For HS256 (HMAC-SHA256), OWASP's JWT Cheat Sheet recommends a secret of at least 256 bits (32 random bytes) — short, guessable, or hardcoded strings like "secret" or a company name are trivially brute-forceable offline once an attacker has a single valid token to test candidate keys against. For RS256 or ES256, the equivalent risk is key management: the private key must never be embedded in client-side code or committed to a repository, and public keys served from a JWKS endpoint should be fetched over TLS and cached with sane rotation, not hardcoded permanently. Mixing algorithm families for the same key pair — using one RSA key for both signing and any HMAC operation anywhere in the same system — is exactly the setup that enables the confusion attacks described above, so security guidance consistently recommends keeping HS256 and RS256 key material in fully separate keystores that a shared code path can't accidentally cross.

How should teams configure JWT libraries to avoid these classes of bugs?

Teams can close most of these gaps with a small, consistent set of configuration rules rather than custom cryptographic code. First, always pass an explicit algorithms allowlist to every verification call — for example, in jsonwebtoken v9+, jwt.verify(token, key, { algorithms: ["RS256"] }) — and never derive the algorithm from the token itself. Second, upgrade past the versions named in CVE-2015-9235, CVE-2022-23540, and CVE-2022-23541; jsonwebtoken v9.0.0 and later removed default none-algorithm support and tightened allowlist enforcement, per Auth0's security bulletin published alongside the release in December 2022. Third, confirm exp and nbf validation is active rather than assumed, since RFC 7519 leaves it optional. Fourth, use distinct, adequately sized keys per algorithm family, and rotate asymmetric keys through a JWKS endpoint rather than hardcoding them. None of this requires writing new cryptography — it requires not disabling the checks the library already offers.

How Safeguard helps

JWT library flaws are a supply-chain problem as much as a configuration problem: the fixes for CVE-2015-9235, CVE-2022-23540, and CVE-2022-23541 all shipped as version bumps, not code rewrites, which means the exposure window for any given codebase is really a question of how quickly a vulnerable dependency gets noticed and updated. Safeguard's SCA and SBOM pipeline tracks exactly this — every build's dependency manifest is checked against known-vulnerable versions of libraries like jsonwebtoken, so a project still pinned below v9.0.0 shows up as an open finding rather than a silent risk, and reachability analysis can confirm whether the vulnerable verify() path is actually exercised by your authentication flow before it gets triaged as urgent.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.