h3llh0und

CVE-2026-107719: the expiry a cache only read when iat was a number

fast-jwt's verifier cache took a token's expiry from exp only inside an if (hasIat) block, so a token without a numeric iat kept verifying after it expired — for ten minutes by default, and without limit at cacheTTL: Infinity. I was the second of four people to report it independently. This is what a duplicate still carries, and the other cache bounds the maintainer realigned while he was there. Fixed in 6.3.4.

Disclaimer. This is personal security research, carried out on my own equipment against a public open-source project. It has no connection to any current or former employer, and no client, employer or production environment was involved at any point. Every token in it was signed and verified by the library in a process on my own machine, with a clock I controlled. The issue was reported privately through the project’s security advisory process. Another researcher’s report reached the maintainers first; mine was closed as a duplicate of it, and I was later added as a co-reporter on that advisory. It is written up here after the advisory, the fixed release and the CVE record were public.

On 4 October my report to fast-jwt was closed. The note from the maintainer was short and kind: a duplicate of an advisory “which was opened before this one”, and please do keep reporting. Twelve hours later that earlier advisory, GHSA-x937-hj6v-793p, was published with the fix in 6.3.4, and I could read for the first time what someone else had written about the same lines of code.

This post is about those lines, and about the two reports, side by side.

Twenty lines in cacheSet

fast-jwt’s verifier can cache its results. Checking a signature costs a cryptographic operation, and a server that sees the same bearer token on every request does not need to pay for it every time. So a successful verification is stored with a validity window, and a later call with the same token inside that window returns the stored payload without verifying anything.

The window is the whole design. It has to close when the token stops being valid, and in 6.3.3 it was computed like this:

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

// Add time range of the token
if (hasIat) {
  cacheValue[1] = !ignoreNotBefore && typeof payload.nbf === 'number' ? payload.nbf * 1000 - clockTolerance : 0

  if (!ignoreExpiration) {
    if (typeof payload.exp === 'number') {
      cacheValue[2] = payload.exp * 1000 + clockTolerance
    } else if (maxAge) {
      cacheValue[2] = payload.iat * 1000 + maxAge + clockTolerance
    }
  }
}

// The maximum TTL for the token cannot exceed the configured cacheTTL
const maxTTL = clockTimestamp + clockTolerance + cacheTTL
cacheValue[2] = cacheValue[2] === 0 ? maxTTL : Math.min(cacheValue[2], maxTTL)

Both exp and nbf are read inside if (hasIat). Only one branch in there actually needs iat: the maxAge fallback, which counts from the moment of issue. The other two had been put inside the same block. iat is optional in a JWT, and RFC 7519 does not tie exp to it. A token with an expiry and no issue time therefore leaves cacheValue[2] at 0, the last line turns that into now + cacheTTL, and the token’s own exp is never read.

The read path then trusts the window:

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

On a hit the payload goes straight back. Nothing re-reads exp and no validator runs. With the default cacheTTL of ten minutes, an expired token keeps working for up to ten minutes after its expiry. The gate came in with commit da11251 in April 2020 and was in every release from v0.1.0 on.

What the first report had

The advisory that came first is by SomnathDas, and it is a good report. Its proof of concept is the shortest honest one available. It signs with noTimestamp: true, which is the library’s own switch for omitting iat, verifies once, moves Date.now one second past exp and verifies again:

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 FAST_JWT_EXPIRED.

It states the invariant the cache was meant to keep in a single line, that a cached token must stop being accepted at the same time as an uncached one. It lays the failure out as a timeline from 12:00:00 to 12:10:00, and it says plainly that the attacker needs a valid token first. The advisory published with PR:L, at 4.2. I had scored PR:N with AC:H, putting the precondition into complexity rather than privileges, and I think the published reading is the better one.

If the question is only whether the maintainers learned about the bug, the second report added nothing. They already had a working reproduction and a correct root cause.

What the second report had

I found the same if, wrote it up independently, and sent it on 18 September with no way of knowing that another report existed. Comparing the two once both were readable, I saw that mine was built around a different argument and covered three things his did not.

The library’s own README promised the opposite. This is the description of cacheTTL as it was:

“If the token has a earlier expiration or the verifier has a shorter maxAge, the earlier takes precedence. Accepts Infinity, which means no cap beyond the token’s own expiry.”

That sentence made this a broken promise and not a judgement call about caching. It also says something about the worst case. cacheTTL: Infinity is a documented, supported setting that 6.3.2 had deliberately allowed again, and with it the window has no upper bound. An expired token without iat stays valid in that process for as long as the cache keeps the entry.

The mitigation the documentation points to did not close it. A reasonable first response is to require iat, using requiredClaims: ['iat']. That check tests presence, 'iat' in payload, while the cache gate tests type, typeof payload.iat === 'number'. A token carrying "iat": "1789698389" or "iat": null passes the requirement and still takes the unguarded path. A reader trying to mitigate would have tried exactly this and been wrong.

There was no test coverage, and the reason was structural. Every cache-expiry test in test/verifier.spec.js built its tokens with createSigner({ expiresIn }), and that always emits iat. The suite could not have produced a token without one.

The rest of my report overlapped with his. Four positive controls ran in each execution. The one doing the work was a verifier configured byte for byte the same, differing only in whether the signer emitted iat, and it threw FAST_JWT_EXPIRED as it should. There was also a list of what the report was not: not the algorithm-confusion fix that had brought me to the repository, which held, not GHSA-687g, which quotes these very lines but is about clockTolerance: Infinity, and not issue #29, which looks identical and was a different bug fixed in 0.2.0.

Every bound, matched to its validator

Steve Goode’s fix, in fc1ddbbe, does more than move exp out of the block. It rewrites the window so that each bound follows its validator:

// Each bound mirrors its validator in createVerifier
cacheValue[1] = !ignoreNotBefore && typeof payload.nbf === 'number' ? payload.nbf * 1000 - clockTolerance : 0

let expiresAt = clockTimestamp + clockTolerance + cacheTTL

if (!ignoreExpiration && typeof payload.exp === 'number') {
  expiresAt = Math.min(expiresAt, payload.exp * 1000 + clockTolerance)
}

// As in its validator, maxAge counts from iat and ignores clockTolerance
if (typeof maxAge === 'number' && typeof payload.iat === 'number') {
  expiresAt = Math.min(expiresAt, payload.iat * 1000 + maxAge)
}

cacheValue[2] = expiresAt

Reading it against the old code shows several fixes that none of the four reports asked for. nbf had been inside the same block, so a token without iat could also be cached before it became valid. When a token had both exp and a maxAge limit, the old code applied only exp. Now it takes the earlier of the two. maxAge used to be extended by clockTolerance, which its validator does not do. It also used to be skipped under ignoreExpiration, and maxAge: 0 was treated as unset. All of that now matches what uncached verification does.

The read path changed as well. The old lookup decided which lower bound applied by reading a code property off the cached value. That property exists on a cached error, but a cached payload is the token’s own claims, and a token can carry a claim called code. The new lookup is just now >= min && now <= max, and every entry now has a real upper bound, so 0 means a point in time and no longer means “no expiry”. The commit adds 260 lines of tests, among them the exp-without-iat cases run across several cacheTTL values.

The README sentence I had built my report on stayed the same. Only its wording was tightened. The code changed until the promise was true.

The record

CVE-2026-107719 was reserved on 8 October and published that evening by GitHub as the CNA. It came in a block with five other fast-jwt identifiers, some for advisories that had been public since July, which matches how pydantic-ai’s identifiers arrived the same week. The record has no credits field. The advisory credits four reporters who found the bug independently — SomnathDas, me, BlueN0r and kagebunsher — and none of them is on the CVE.

That is the ordinary outcome when GitHub numbers an advisory, and here it is at its least surprising. A record would have had to pick among four reporters of one bug, and the advisory already does that job, with the order intact.


Advisory GHSA-x937-hj6v-793p · CVE CVE-2026-107719 · Fix fc1ddbbe · Affected < 6.3.4, fixed in 6.3.4

My report sent 18 September · closed as a duplicate of GHSA-x937 on 4 October · advisory and fix published 4 October · added as co-reporter 5 October · CVE reserved and record published 8 October, crediting SomnathDas, me, BlueN0r and kagebunsher as reporters on the advisory.

Eurico Nicacio — @h3llh0und

← all posts Eurico Nicacio · h3llh0und