GHSA-687G-22H4-J4W4
Vulnerability from github – Published: 2026-10-08 22:02 – Updated: 2026-10-08 22:02Summary
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
clockToleranceto"Infinity"(string);Number("Infinity") === Infinity. Surprising via JSON.parse + Number cast or evenJSON.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
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 6.2.4"
},
"package": {
"ecosystem": "npm",
"name": "fast-jwt"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "6.3.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-107721"
],
"database_specific": {
"cwe_ids": [
"CWE-613",
"CWE-682"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-08T22:02:02Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"details": "## Summary\n\n`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\u0027s internal cache so cached entries inherit infinite validity \u2014 they remain valid past a later developer-removed Infinity config until LRU eviction.\n\n## Vulnerable code\n\n`src/verifier.js:531-533` \u2014 option validation only rejects negative values, not Infinity:\n\n```js\nif (clockTolerance \u0026\u0026 (typeof clockTolerance !== \u0027number\u0027 || clockTolerance \u003c 0)) {\n throw new TokenError(TokenError.codes.invalidOption, \u0027The clockTolerance option must be a positive number.\u0027)\n}\n```\n\n`Infinity` passes (it\u0027s a number, not less than 0, truthy).\n\n`src/verifier.js:583-602` \u2014 clockTolerance flows into the date-claim validators:\n\n```js\nif (!ignoreNotBefore) {\n validators.push({ ..., modifier: -clockTolerance }) // \u2192 -Infinity\n}\nif (!ignoreExpiration) {\n validators.push({ ..., modifier: +clockTolerance }) // \u2192 Infinity\n}\n```\n\n`src/verifier.js:198-205` \u2014 applies modifier additively, producing always-pass comparisons:\n\n```js\nfunction validateClaimDateValue(value, modifier, now, greater, errorCode, errorVerb) {\n const adjusted = value * 1000 + (modifier || 0) // \u2192 \u00b1Infinity\n const valid = greater ? now \u003e= adjusted : now \u003c= adjusted // \u2192 always true\n ...\n}\n```\n\n## Empirical PoC\n\n```js\nconst { createSigner, createVerifier } = require(\u0027fast-jwt\u0027)\nconst secret = \u0027test-secret-with-enough-length-to-pass\u0027\n\nconst sign = createSigner({ key: secret, algorithm: \u0027HS256\u0027 })\nconst expiredToken = sign({\n sub: \u0027alice\u0027,\n iat: Math.floor(Date.now()/1000) - 3600,\n exp: Math.floor(Date.now()/1000) - 1800, // expired 30 min ago\n})\n\nconst v1 = createVerifier({ key: secret })\ntry { v1(expiredToken) } catch (e) { console.log(\u0027baseline rejects:\u0027, e.message) }\n// \u2192 \"The token has expired at ...\"\n\nconst v2 = createVerifier({ key: secret, clockTolerance: Infinity })\nconsole.log(\u0027bypass:\u0027, v2(expiredToken))\n// \u2192 { sub: \u0027alice\u0027, iat: ..., exp: ... } \u2190 expired token accepted as valid\n\n// Not-yet-active token (nbf in 1 day) \u2014 same bypass\nconst futureToken = sign({\n sub: \u0027bob\u0027,\n iat: Math.floor(Date.now()/1000),\n nbf: Math.floor(Date.now()/1000) + 86400,\n})\nconsole.log(\u0027bypass nbf:\u0027, v2(futureToken))\n// \u2192 { sub: \u0027bob\u0027, ... } \u2190 not-yet-active token accepted as valid\n```\n\nVerified against `fast-jwt@HEAD` on 2026-06-03 (commit pulled today).\n\n## Cache side-effect\n\nThe verifier\u0027s LRU cache uses `clockTolerance` to compute the cache entry\u0027s expiry window:\n\n`src/verifier.js:121-134`:\n```js\ncacheValue[1] = ... payload.nbf * 1000 - clockTolerance : 0 // \u2192 -Infinity\ncacheValue[2] = payload.exp * 1000 + clockTolerance // \u2192 Infinity\nconst maxTTL = clockTimestamp + clockTolerance + cacheTTL // \u2192 Infinity\n```\n\nWith `clockTolerance: Infinity`, cache entries are stored with `[min=-Infinity, max=Infinity]`. The cache-hit check (`min === 0 || now \u003c min || now \u003c= max`) always passes for cached tokens.\n\n**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).\n\n## Asymmetric hardening \u2014 the smoking-gun shape\n\n`src/signer.js:98, 104` CORRECTLY uses `Number.isFinite()` to reject Infinity for `expiresIn` and `notBefore`:\n\n```js\nexpiresIn != null \u0026\u0026 Number.isFinite(expiresIn)\n ? Math.floor((iat + expiresIn) / 1000)\n : ...\n```\n\nThe verifier\u0027s `clockTolerance` validation doesn\u0027t apply the same guard. The same `\u003c 0` check pattern is also applied to `clockTimestamp` (line 527-529) and `cacheTTL` (line 535-537) \u2014 both also accept `Infinity`.\n\nThis is the kind of asymmetry that often indicates a missed hardening pass: the sign-side was hardened against Infinity but the verify-side wasn\u0027t.\n\n## Threat model\n\nThe bug requires the developer to (mis)configure `clockTolerance: Infinity`. Realistic ways this happens:\n\n1. **Developer using Infinity as a sentinel** for \"disable expiry\": common JS idiom; many libraries accept Infinity as \"no limit.\" fast-jwt\u0027s signer treats Infinity as invalid (Number.isFinite false) but the verifier silently accepts it.\n2. **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(\u0027{\"clockTolerance\": null}\u0027)` if the codec accepts null \u2192 Infinity.\n3. **Test config bleed**: integration tests use Infinity to make tokens never expire during long-running tests; the config bleeds into production.\n\nFor a JWT library, silently disabling token expiry on a \"looks like a non-negative number\" input is a security boundary failure.\n\n## Suggested fix\n\nSingle-line addition: use `Number.isFinite()` consistent with signer.js:\n\n```js\nif (clockTolerance \u0026\u0026 (typeof clockTolerance !== \u0027number\u0027 || !Number.isFinite(clockTolerance) || clockTolerance \u003c 0)) {\n throw new TokenError(TokenError.codes.invalidOption, \u0027The clockTolerance option must be a finite, non-negative number.\u0027)\n}\n```\n\nSame fix for `clockTimestamp` (line 527-529) and `cacheTTL` (line 535-537) for consistency.\n\nOptional 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 \u003c60s tolerance.\n\n## Affected versions\n\nAll versions since clockTolerance was first introduced in PR #193 (v1.5.1). Current `main` HEAD on 2026-06-03 is affected.\n\n## Reporter\n\nAndrew Ridings (independent security researcher). Happy to coordinate disclosure timing and follow up with any clarifications. Email: ridingsa@gmail.com",
"id": "GHSA-687g-22h4-j4w4",
"modified": "2026-10-08T22:02:03Z",
"published": "2026-10-08T22:02:02Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/nearform/fast-jwt/security/advisories/GHSA-687g-22h4-j4w4"
},
{
"type": "WEB",
"url": "https://github.com/nearform/fast-jwt/commit/c0fb5b88b4c88ff2bdd194ca2c713599ad9e38b6"
},
{
"type": "PACKAGE",
"url": "https://github.com/nearform/fast-jwt"
},
{
"type": "WEB",
"url": "https://github.com/nearform/fast-jwt/releases/tag/v6.3.0"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:H/PR:H/UI:N/S:U/C:H/I:H/A:N",
"type": "CVSS_V3"
}
],
"summary": "fast-jwt clockTolerance: Infinity silently bypasses both exp and nbf validation (and persists in the verifier cache)"
}
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.