Blog / CVE explainers

fast-jwt algorithm confusion: fixed on the third try

fast-jwt has patched RSA to HS256 algorithm confusion three times. CVE-2026-107722 shows why the first two fixes closed an input instead of the rule.

Completed doctoral studies in Information SecurityUniversity defense passed · Final dissertation defense pending

The same bug, patched three times

No password. Admin access anyway. That’s what a forged token buys against fast-jwt’s RS256 verifier — a bug patched three times before the fix actually held. On 8 October 2026 the GitHub Advisory Database published CVE-2026-107722 — the report behind the fix that finally held. [1] Read the title slowly: "Incomplete patch of CVE-2026-34950". Now read the title of CVE-2026-34950, from April: "Incomplete fix for CVE-2023-48223". [3]

Three advisories, one library, one bug class. Each fix closed the exact input the previous researcher had demonstrated, and each time somebody arrived with a slightly different input and walked straight through.

That chain is the story. The vulnerability itself is textbook algorithm confusion, and if that is all you take away you will patch to 6.3.0 and move on. The part worth your time is why two competent fixes failed and the third one did not, because that difference is a thing you can apply to your own patches on Monday.

I have not reproduced this one. Everything below comes from the advisory, the advisory before it, and the diff that fixed them. [1][2][3]

CVE-2023-48223   the original
      v
CVE-2026-34950   "incomplete fix"   9.1
      v                (added trim)
CVE-2026-107722  "incomplete patch" 9.8

The server believes the key. The attacker controls the string

JWT algorithm confusion, in one sentence: the token tells the server how to verify the token. If an application signs with RS256 and the verifier can be talked into HS256 instead, the RSA public key stops being a public key and becomes a shared secret. It is public. The attacker has it. They sign a token with it, the server computes the same HMAC, and the signature matches.

What makes fast-jwt interesting is where that decision gets made. The library works out whether your key is asymmetric by looking at the key as a string: if it looks like a PEM block, treat it as RSA; if it does not, treat it as a raw HMAC secret. [1]

That is a reasonable thing to do and almost every JWT library does something like it. It also means the classification depends on text matching, and text matching has edge cases that cryptography does not.

Why trim() looked like the right fix

In April, CVE-2026-34950 reported that a leading space defeated the PEM check. [3] The matcher was a regex anchored at the start of the string, so a key beginning with whitespace did not match, got classified as a raw secret, and the HMAC path opened up.

The fix in 6.2.0 was a call to trim() before the check. [1][2] Minimal, obviously correct for the reported input, shipped with a regression test. I would guess most reviewers approved it in under a minute, and I would probably have been one of them.

But the reported input was never the bug. The bug was the anchor. A regex beginning with a caret encodes an assumption: the PEM header sits at position zero. trim() does not remove that assumption, it just makes it true for one category of character, because String.prototype.trim removes only what the ECMAScript specification calls whitespace. [1]

Everything else still works. The regression tests that shipped with the real fix enumerate what "everything else" means in practice: a NUL byte, an escape character, DEL, zero-width characters, a comment line, an HTTP wrapper. [2] Any one of them in front of the PEM header and the key is a raw secret again.

The advisory puts it better than I can: the fix was textually narrow, whitespace only, rather than addressing the class. [1]

// 6.1.0 and earlier
/^-----BEGIN(?: (RSA))? PUBLIC KEY-----/

// 6.2.0: trim() first, same anchor
trimmedKey.match(publicKeyPemMatcher)

// a leading NUL still walks past it

The third fix changed the rule, not the input

Look at what 6.3.0 does differently, because it is not a longer strip list. [2]

It stops anchoring. A new matcher finds a PEM header anywhere in the string, and the key is sliced from there. A key is treated as a raw HMAC secret only when it contains no PEM header at all, and once a header is present the key can never fall back to HMAC. The X.509 check moves from includes to startsWith. A key that holds PEM markers but does not classify cleanly now throws instead of quietly falling through to the HMAC branch.

The commit message states the new rule in one line, and it is the kind of sentence worth stealing for your own codebase: once a header is present it must classify as asymmetric key material and can never fall back to HMAC. [2]

The first two fixes blocklisted an input. The third allowlisted a shape. That is the whole difference, and it is why this one closes the class instead of one more instance of it.

// 6.3.0 - find it anywhere
/-----BEGIN [A-Z0-9 ]+?-----/

const { pem, isRawSecret } =
  locatePem(trimmedKey)

if (isRawSecret) return hsAlgorithms
// unless algorithms explicitly pins HS256 - that still skips this check

Pinning the algorithm helps. Pinning the wrong one does not

Here is the part I would bet gets people. The advisory lists the vulnerable configurations: no algorithms allowlist, an async key callback, or algorithms set to HS256. The configuration it lists as protected is algorithms set to RS256. [1]

Pinning the algorithm is the standard advice for algorithm confusion, and it is correct advice. But a team that skims an advisory, sees HS256 in the attack description and pins to HS256 has pinned the verifier to the attacker's choice. The allowlist has to name the algorithm you actually sign with, not the one you read about in the writeup.

When I review authentication code, this is the shape I look for first: a defensive control configured from the incident report rather than from the system's own design. It passes review because it is visibly a control, and it is pointing the wrong way.

Is your service actually exposed?

Narrower than 9.8 suggests, and three things have to line up. [1]

First, the resolved version of fast-jwt is between 6.2.0 and 6.2.4 for this specific CVE — 6.1.0 and earlier is not safe either, just exposed to the second link in the chain, CVE-2026-34950, instead. Check what npm actually installed, not what package.json asks for, and check the transitive one: fast-jwt is the default JWT backend of @fastify/jwt, so plenty of teams are running it without having chosen it. [1]

Second, the verifier is configured one of the vulnerable ways above. Third, and this is the one nobody can check for you, your configured key carries a non-whitespace character before its PEM header. A clean key is not affected at all.

That last condition is a property of how the key reaches the process rather than of your code. Read from a file, pasted into an environment variable, pulled from a secret manager, concatenated with a label by a deployment script. I have no data on how often that happens in the wild and neither does the advisory, so treat it as where to look rather than as a probability.

What to check today

Three things, in the order I would do them.

Upgrade to 6.3.0 and confirm the resolved version afterwards. Then set an explicit algorithms allowlist naming the asymmetric algorithm your service genuinely uses. Then, and this is the one that outlives this CVE, assert the shape of your verification key at startup rather than trusting whatever the file or the environment variable handed you.

That third check costs one line and it would have made the last two of these CVEs unexploitable on your service, including the next one. (The first, CVE-2023-48223, was a different shape — a valid key the old matcher failed to recognise, not a junk prefix in front of one — so this specific check would not have caught it.)

Which is the real lesson. A library that has patched the same class three times will probably be back. A key loader that refuses anything which is not exactly a PEM block will not care.

# the version that actually loads
npm ls fast-jwt

# pin the algorithm you really use
{ algorithms: ['RS256'] }

# assert the key's shape at boot
key.startsWith('-----BEGIN')

Patch the rule, not the report

Every one of these fixes was made by someone competent, responding to a real report, with a test. The failure was not care. It was scope: the report described an input, so the fix addressed the input, and the rule that made the input dangerous stayed exactly as it was.

The question I ask on a patch review is deliberately annoying: what is the class of input this report is one example of, and does the fix close the class? If the answer is no, you have scheduled the next CVE rather than prevented it.

That question is most of what a secure code review is. Tracing a report back to the decision that made it possible, then checking whether the fix moved the decision or just the symptom, is work I do on client code regularly, and it is the kind of thing a scanner will never flag because the patched version number looks correct. If you want attacker's eyes on a fix before someone else finds the gap in it, that is the conversation to start.

Sources

Need this checked in your own product?

Security code review
All articles

08Contact

Shipping something that handles user data?

Send me a short brief. I reply within three days with questions or a proposal outline, and the price is agreed in writing before work starts.