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:
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:
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:
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
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:
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:
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:
- 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.
- 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.
- 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:
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
Summary
createVerifier({ clockTolerance: Infinity })silently bypasses bothexp(expiry) ANDnbf(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:Infinitypasses (it's a number, not less than 0, truthy).src/verifier.js:583-602— clockTolerance flows into the date-claim validators:src/verifier.js:198-205— applies modifier additively, producing always-pass comparisons:Empirical PoC
Verified against
fast-jwt@HEADon 2026-06-03 (commit pulled today).Cache side-effect
The verifier's LRU cache uses
clockToleranceto compute the cache entry's expiry window:src/verifier.js:121-134: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, 104CORRECTLY usesNumber.isFinite()to reject Infinity forexpiresInandnotBefore:The verifier's
clockTolerancevalidation doesn't apply the same guard. The same< 0check pattern is also applied toclockTimestamp(line 527-529) andcacheTTL(line 535-537) — both also acceptInfinity.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:clockToleranceto"Infinity"(string);Number("Infinity") === Infinity. Surprising via JSON.parse + Number cast or evenJSON.parse('{"clockTolerance": null}')if the codec accepts null → Infinity.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:Same fix for
clockTimestamp(line 527-529) andcacheTTL(line 535-537) for consistency.Optional defense-in-depth: cap
clockToleranceto a reasonable upper bound (e.g., 5 minutes = 300000 ms) with aprocess.emitWarningabove that. Most legitimate use cases need <60s tolerance.Affected versions
All versions since clockTolerance was first introduced in PR #193 (v1.5.1). Current
mainHEAD 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