Safeguard
Application Security

Secure JWT handling: algorithm confusion, expiry, and storage done right

A single unchecked `alg` header turned jsonwebtoken into a forgeable token in CVE-2015-9235 — here's how to close every hole RFC 8725 warns about.

Safeguard Research Team
Research
6 min read

JSON Web Tokens have been an IETF standard since RFC 7519 shipped in 2015, and in the decade since, the same handful of implementation mistakes have produced a recurring stream of CVEs against the libraries developers trust to verify them. CVE-2015-9235 let attackers forge tokens against jsonwebtoken before v4.2.2 simply by setting the header's alg field to none and stripping the signature entirely. CVE-2016-10555 showed that servers accepting both RS256 and HS256 could be tricked into verifying an HMAC-signed forgery using the server's own public RSA key as the shared secret. And as recently as 2022, CVE-2022-23529 — a CVSS 7.6 remote-code-execution flaw in jsonwebtoken versions up to and including 8.5.1, discovered by Palo Alto Unit 42 and later formally retracted once its exploitation prerequisites were scrutinized — showed that even a widely audited library could still be misused into executing attacker-controlled code under the right conditions. None of these were exotic cryptographic breaks; they were implementation and integration mistakes the IETF's RFC 8725, "JSON Web Token Best Current Practices," was written specifically to prevent. This post walks through what actually goes wrong with JWTs in production — algorithm confusion, key handling, expiry, and client-side storage — and what RFC 8725 and the OWASP JWT Cheat Sheet recommend instead.

What is algorithm confusion, and why did alg: none ever work?

Algorithm confusion happens when a JWT library trusts the algorithm named in the token's own header instead of enforcing an algorithm the server chose in advance. Early JWT implementations, including jsonwebtoken before v4.2.2 (CVE-2015-9235), read the alg field from the untrusted, attacker-editable header and dispatched verification accordingly — and their none verifier returned true for a signature of zero length. An attacker could take any valid token, change the header to {"alg":"none"}, delete the signature segment, and edit the payload's claims freely; the server would accept it. RFC 8725 responds directly to this class of bug: it requires that verifiers be configured with an explicit allowlist of acceptable algorithms supplied by the application, never inferred from the token, and that none be rejected outright unless a system deliberately supports unsecured JWTs (which RFC 8725 discourages entirely in authentication contexts). Modern library APIs reflect this lesson — jsonwebtoken's verify() now requires callers to pass an algorithms array rather than defaulting to "whatever the token says."

How does the RS256/HS256 key-confusion attack actually work?

CVE-2016-10555, affecting jwt-simple and similar libraries, is the canonical example of key-type confusion: a server designed to issue RS256 tokens (asymmetric — signed with a private key, verified with a public key) also accepted HS256 (symmetric — signed and verified with the same shared secret) without checking that the algorithm matched the key type it expected. Because RSA public keys are, by design, not secret, an attacker who obtains the server's public key — often published openly at a /.well-known/jwks.json endpoint — can use that public key as the "shared secret" for an HS256 signature. The server, told to expect HS256, hands the attacker-supplied key material to its HMAC verifier, computes a valid signature, and accepts a token the attacker fully controls. RFC 8725 closes this by requiring implementations to maintain separate, non-overlapping key sets per algorithm and key type, and to never allow a single key value to be reinterpreted across algorithm families. Practically: never configure a verifier to accept "RS256 or HS256" against the same key material.

What went wrong in CVE-2022-23529, and is it still exploitable today?

CVE-2022-23529 affected jsonwebtoken (maintained under Auth0/OpenJS) versions 8.5.1 and earlier, carrying an initial CVSS score of 7.6. When jwt.verify() was called with secretOrPublicKey passed as an object rather than a string or buffer, and no algorithms restriction was enforced, an attacker who controlled that object could supply their own toString() method, which the library would invoke before its certificate-format check — resulting in arbitrary file write and, from there, potential remote code execution. Unit 42, which discovered and reported the flaw in mid-2022, and Auth0's advisory both flagged that exploitation has real prerequisites: an application has to let untrusted input reach the key-lookup parameter in the first place. Those prerequisites turned out to matter enough that Unit 42 and Auth0 formally retracted the CVE, concluding the risk lives in how calling code uses the library rather than in the library itself — so, no, it is not "still exploitable" in the sense a live CVE number implies, though the underlying pattern (untrusted input flowing into secretOrPublicKey) is still worth guarding against. The fix, shipped in jsonwebtoken v9.0.0, tightened type-checking on the key argument and made explicit algorithms configuration mandatory in more code paths. Any codebase still pinned below v9.0.0 that passes untrusted or dynamically constructed values into key resolution is exposed to the same class of bug, retraction notwithstanding — a good candidate for a dependency scan against jsonwebtoken specifically, not just a generic "any JWT library" check.

How should access tokens expire, and what should rotate?

RFC 8725 recommends short-lived access tokens paired with a separate, longer-lived refresh token that the server can revoke — because a JWT, once issued, is self-contained and normally cannot be invalidated before its exp claim passes. Setting access-token TTLs in the range of minutes, rather than hours or days, limits the damage window if a token leaks, while a refresh token — rotated on each use and checked against a server-side store — lets the backend detect reuse of a stolen refresh token and revoke the whole session. RFC 8725 also requires verifying exp (expiration), nbf (not-before), aud (intended audience), and iss (issuer) on every verification call, not just checking the signature; a validly signed token with the wrong audience is still a forgery in practice if the audience check is skipped. Because JWTs aren't natively revocable, teams that need hard logout guarantees still need a server-side denylist or a short enough TTL that revocation is rarely needed.

Where should a JWT be stored on the client, and why does it matter?

The OWASP JWT Cheat Sheet's long-standing guidance is to avoid localStorage and sessionStorage for tokens, because both are readable by any JavaScript running on the page — meaning a single XSS vulnerability anywhere in the app's dependency tree can exfiltrate every active session token. The recommended alternative is an httpOnly, Secure, SameSite=Strict (or Lax, depending on cross-site flow needs) cookie: httpOnly blocks script access entirely, Secure ensures it's only sent over TLS, and SameSite limits cross-origin submission, mitigating CSRF alongside XSS-driven theft. This pairs naturally with the expiry model above — a short-lived access token in memory or an httpOnly cookie, refreshed via a rotating token the browser never exposes to JavaScript at all. Teams building single-page apps that insist on localStorage for convenience are trading a well-understood, mitigable risk (CSRF, addressed with tokens or SameSite) for a much harder one (any XSS anywhere becomes full session takeover) — which is why RFC 8725 and OWASP both steer implementers toward cookie-based storage as the default, not the exception.

Never miss an update

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