fast-jwt clockTolerance: Infinity silently bypasses both exp and nbf validation (and persists in the verifier cache)
## 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
describes (1)
Related CVEs.
| ID | severity | exploitable | ||
|---|---|---|---|---|
| CVE-2026-107721US | — | — | ||
fast-jwt provides fast JSON Web Token (JWT) implementation. Prior to 6.3.0, fast-jwt createVerifier accepts Infinity for clockTolerance because its option validation checks type an… | ||||
references
External citations — patches, advisories, analysis.
Advisories like this name the exposed products attackers go after on your perimeter. See what is exposed on your perimeter with a free passive scan.
Free scan