Skip to content
100% in your browser. Nothing you paste is uploaded — all processing runs locally. Read more →

fast-jwt CVE-2026-34950: how leading whitespace re-enabled algorithm confusion

8 min read #jwt #security #cve #algorithm-confusion #fast-jwt

The RS256-to-HS256 algorithm-confusion attack has been documented since 2015. Most JWT libraries now block it. CVE-2026-34950 is a reminder that a fix can have a gap.

In fast-jwt, a Node.js JWT library, versions 6.1.0 and earlier could mistake an RSA public key for an HMAC secret if the key string started with whitespace: a space, a tab or a newline. That re-opened the exact attack that CVE-2023-48223 had fixed in 2023. The advisory rates it Critical (CVSS 9.1). It’s fixed in 6.2.0.

This post covers the attack, what went wrong in fast-jwt, how to tell whether you’re affected, and what to check in your own code whatever library you use.

The attack class: algorithm confusion

A JWT header names the algorithm used to sign it. Two families matter here:

Say your service signs tokens with RS256. The public key is public, by design. An attacker builds a token with "alg": "HS256" and computes an HMAC over it using your public key as the HMAC secret.

If the verifier lets the token’s header choose the algorithm, it runs HMAC-SHA256 with the public key as the secret. The result matches what the attacker computed, and the forged token is accepted.

RFC 8725, the JWT best-practices RFC, describes this attack and cites CVE-2015-9235, a 2015 bug in the Node.js jsonwebtoken library (fixed in 4.2.2). Its fix, in section 3.1, is that the caller chooses which algorithms are allowed, and “each key MUST be used with exactly one algorithm.”

The fast-jwt history

With fast-jwt, you create a verifier with createVerifier(). The algorithms option is optional. The README says: “By default, all algorithms are accepted.” In practice, if you leave algorithms out, fast-jwt looks at the key you passed and picks the allowed algorithms from it. An RSA public key allows the RSA algorithms. A plain string allows HS256, HS384 and HS512.

That detection is where both bugs lived.

CVE-2023-48223 (2023). fast-jwt recognised a public key by looking for the text -----BEGIN PUBLIC KEY-----. An RSA key in the older PKCS #1 format starts with -----BEGIN RSA PUBLIC KEY----- instead, so it wasn’t recognised and was treated as an HMAC secret. Version 3.3.2 (November 2023) fixed this by switching to a regular expression that matches both headers. In 3.3.2 it read:

const publicKeyPemMatcher = /^-----BEGIN( RSA)? PUBLIC KEY-----/

The CVE-2026-34950 gap

The ^ at the start of that regular expression means “at the very start of the string.” If the key string has anything in front of the PEM header, even one space or newline, the match fails. fast-jwt then decided the key wasn’t a public key and fell back to treating it as an HMAC secret. That’s the same outcome as the 2023 bug. The code that detects private keys trimmed whitespace first. The public-key check didn’t.

The advisory points out that leading whitespace is common in real deployments. Keys loaded from database text columns, YAML multi-line strings, environment variables and copy-pasted config files can all pick up a leading newline.

You were affected if all of these were true:

The attacker needs nothing except your public key.

The advisory was published on April 2, 2026. The fix shipped in 6.2.0 on April 7, 2026, via pull request #598, which trims the key before checking what kind of key it is. That fix turned out to be incomplete; see step 1 under “What to do”.

How to tell if you’re affected

Check which version you have. fast-jwt is often installed indirectly, for example through @fastify/jwt, so look at the whole tree:

npm ls fast-jwt
# or
pnpm ls fast-jwt

# Affected by CVE-2026-34950: 6.1.0 and earlier
# Fixed for that CVE:         6.2.0 and later
# Later advisories: see step 1 below (use 6.3.1 or later)

npm audit also reports this advisory. One side effect can help you spot a problem key. On an affected version, a whitespace-prefixed key also makes fast-jwt reject your real RS256 tokens with “The token algorithm is invalid.” The advisory’s proof of concept shows this. If you’ve seen that error, check the key string. Don’t treat the absence of that error as proof you’re safe.

What to do

  1. Upgrade. 6.2.0 fixes CVE-2026-34950. It isn’t the last fast-jwt fix, though. Later advisories were fixed in 6.2.1 (CVE-2026-35040, CVE-2026-35041) and 6.2.4 (CVE-2026-44351, a critical bypass involving async key resolvers). Advisories published in July 2026 include GHSA-ww5h-9m49-7xx4 (the 6.2.0 fix was incomplete: keys with leading content other than whitespace could still be treated as an HMAC secret; fixed in 6.3.0) and GHSA-8wpc-h4q6-8fxv (an empty or null key with algorithms set could accept unsigned tokens; fixed in 6.3.1). Move to the latest release and no lower than 6.3.1. Check the advisories list for anything newer.

  2. Pin the algorithm. Pass algorithms every time:

    import { createVerifier } from "fast-jwt";
    
    const verify = createVerifier({
      key: publicKey,
      algorithms: ["RS256"],
    });
    const payload = verify(token);

    With algorithms: ["RS256"], even an affected version refuses to treat the key as an HMAC secret. It throws “Invalid public key provided for algorithms RS256.” instead of accepting a forged token. That error is a loud failure, which is what you want.

  3. Load keys carefully. publicKey.trim() is what the 6.2.0 fix does, and it costs nothing, but it turned out not to be enough (see step 1). Treat pinning algorithms as the main defence, and make sure the key you pass in is the PEM you expect, never an empty string or null.

  4. Check your logs (see below) for tokens verified with an alg you don’t issue.

What to check in your own JWT code

The same class of bug turns up in any language when the verifier lets the token pick its algorithm.

1. Are you passing algorithms to your verifier?

Most JWT libraries have this option. Always pass it explicitly. Don’t rely on the default, because defaults differ between libraries and versions.

# PyJWT
import jwt
jwt.decode(token, public_key, algorithms=["RS256"])
// golang-jwt v5
token, err := jwt.Parse(tokenString, func(t *jwt.Token) (any, error) {
    return publicKey, nil
}, jwt.WithValidMethods([]string{"RS256"}))
// jsonwebtoken (Node.js)
import jwt from "jsonwebtoken";
jwt.verify(token, publicKey, { algorithms: ["RS256"] });
// jsonwebtoken (Rust)
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
let validation = Validation::new(Algorithm::RS256);
decode::<Claims>(token, &DecodingKey::from_rsa_pem(public_key_pem)?, &validation)?;

2. Are you rejecting alg: none?

none is a separate attack, not algorithm confusion. It’s a registered value meaning “no signature,” and RFC 7518 says implementations “MUST NOT accept Unsecured JWSs by default.” Some libraries used to accept them anyway. Pinning algorithms covers this too. You can paste a token into the JWT decoder to see its alg.

3. Does each key match one algorithm?

If you fetch keys from a JWKS endpoint, the kid in the token only tells you which key to use. It says nothing about which algorithm is allowed with it. Check that the key type matches the algorithm you expect: an RSA key for RS256, an EC key for ES256. If the JWK carries an alg, check that too. RFC 8725 also warns that kid is untrusted input. Validate it before using it in a lookup, because it can carry SQL or LDAP injection.

4. Would your logs show forged tokens?

If someone started sending HS256 tokens signed with your public key, would you notice?

Test your own verifier

You can check a verifier against this attack in a few minutes. Run it against a test instance you own, not someone else’s service.

// forge-test.mjs: prints an HS256 token signed with your RSA public key
import { createHmac } from "node:crypto";
import { readFileSync } from "node:fs";

// Use the exact string your verifier loads, including any leading whitespace
const publicKey = readFileSync("public.pem", "utf8");
const b64url = (obj) => Buffer.from(JSON.stringify(obj)).toString("base64url");
const header = b64url({ alg: "HS256", typ: "JWT" });
const payload = b64url({ sub: "forged-test", exp: Math.floor(Date.now() / 1000) + 300 });
const sig = createHmac("sha256", publicKey).update(`${header}.${payload}`).digest("base64url");
console.log(`${header}.${payload}.${sig}`);

Send the printed token to your verifier. If it’s accepted, you have an algorithm-confusion bug. Try it once with the key as stored and once with a newline added in front.

Use a script for this rather than our Sign tool. Sign’s secret box is a single-line field, so a multi-line PEM pasted into it loses its line breaks and won’t match the bytes your server uses.

Do you need to rotate keys?

Algorithm confusion is a flaw in the verifier’s logic. It doesn’t leak your private key, so upgrading doesn’t by itself mean rotating keys. What you do need to do:

  1. Upgrade the library.
  2. Search your logs for any token that passed verification with an alg you don’t issue. If you find one, treat it as a breach: rotate keys, end sessions and force users to sign in again.

If your private key may have been exposed some other way, such as a leaked credential or a compromised CI system, rotate it regardless.

The big picture

Bugs in JWT libraries are usually about how keys and algorithms are matched up, not about broken cryptography. This one came down to a single ^ in a regular expression and a missing .trim(). The same three habits defend against the whole class in every language:

Pin the expected algorithm. Don’t let the token’s header decide. Treat every key-loading path as input that needs checking.

Sources and further reading