Like a locksmith who quietly stops adding deadbolts whenever you ask for a different key color
CVE-2026-110024 is a logic bug in @fastify/jwt versions prior to 10.2.5. When a developer calls the top-level fastify.jwt.sign() or fastify.jwt.verify() methods and passes *any* per-call option (e.g., { algorithm: 'RS256' }), the library replaces the entire global defaults object instead of merging. The result: every global default the call doesn't explicitly restate is silently dropped — including expiresIn and maxAge. Tokens minted through this path carry no exp claim and never expire; verification stops enforcing maximum age. The affected range is @fastify/jwt ≥ 9.0.0 < 10.2.5 (all versions where the top-level API exists). The route-level decorators reply.jwtSign() and request.jwtVerify() already merge correctly and are not affected.
The vendor's MEDIUM 6.8 is slightly generous for real-world exposure. The CVSS vector correctly flags AC:H and PR:L — the attacker can't *trigger* this bug remotely; it's the application's own code that silently produces bad tokens. The attacker merely benefits from a token that was minted without expiration, either through normal auth flows or token theft. More importantly, the documented and recommended pattern in the Fastify ecosystem is route-level decorators, which are safe. Only developers who use the top-level API for background services, token-issuance helpers, or CLI tooling hit this path. In a fleet of Fastify apps, a meaningful but minority fraction are exposed. We downgrade to 5.5 to reflect that narrowed blast radius.
4 steps from start to impact.
Application uses vulnerable code path
@fastify/jwt < 10.2.5 with global temporal defaults (expiresIn, maxAge). Somewhere in the codebase — typically a token-issuance service, background job, or helper outside a route handler — the developer calls fastify.jwt.sign(payload, { ...perCallOptions }) with at least one per-call option. This silently replaces the entire defaults object, dropping expiresIn.- Application uses @fastify/jwt < 10.2.5
- Developer calls top-level
fastify.jwt.sign()with per-call options - Global defaults include temporal protections (
expiresIn,maxAge)
- Route-level decorators (
reply.jwtSign) are the documented/recommended pattern and are NOT affected - Only top-level API callers with per-call options trigger the bug
- Developer must have configured global temporal defaults for the drop to matter
fastify.jwt.sign( with a second argument.Token minted without expiration
exp claim. The token is returned to the user (or stored for later use) as if it were a normal short-lived token. Neither the application nor the user receives any error or warning. The token is cryptographically valid and accepted by all verifiers that trust the signing key.- Step 1 code path is exercised
- If the application independently checks
expat the application layer (rare but possible), the missing claim may be caught - Tokens minted via route decorators in the same application remain correctly bounded
exp claim. Log analysis for tokens with abnormally long lifetimes.Verification fails to enforce maxAge
fastify.jwt.verify(token, { ...perCallOptions }), the global maxAge default is similarly dropped. The verifier accepts tokens of any age, including the never-expiring tokens from Step 2. Even tokens that SHOULD have expired based on global config are accepted.- Application uses top-level
fastify.jwt.verify()with per-call options - Global
maxAgewas configured but gets dropped
- Route-level
request.jwtVerify()still enforces maxAge correctly - Applications that don't pass per-call options to verify are unaffected
Attacker achieves persistent access
- Attacker possesses a token minted through the vulnerable path
- Application relies on JWT expiration as a revocation mechanism (stateless JWT pattern)
- Applications with server-side session stores or token blocklists can revoke tokens independently of
exp - Short-lived refresh-token architectures limit the blast radius even if access tokens lack
exp - Attacker still needs to obtain a valid token through some means (PR:L or separate vuln)
exp claim.fastify.jwt.sign() / fastify.jwt.verify() with reply.jwtSign() / request.jwtVerify() inside route handlers. These decorators already merge per-call options with global defaults correctly and are completely unaffected by CVE-2026-110024. This is a code change, not a config change, so test before deploying. As a MEDIUM verdict, this falls under the noisgate remediation SLA of 365 days, but given the simplicity of the fix, aim to complete within 30 days.expiresIn and maxAge to every per-call options object passed to fastify.jwt.sign() and fastify.jwt.verify(). This is the vendor-recommended workaround. Example: fastify.jwt.sign(payload, { algorithm: 'RS256', expiresIn: '15m' }). This eliminates the silent drop. Apply within the noisgate remediation SLA of 365 days for MEDIUM.npm install @fastify/[email protected] or update your lockfile. The patch merges per-call options with global defaults instead of replacing them. No breaking changes reported. Deploy within the noisgate remediation SLA of 365 days.exp claim. This defends against both this CVE and all other token-lifetime bugs. This is a defense-in-depth measure that reduces the residual severity of any JWT expiration flaw.exp claim. This catches tokens minted through the vulnerable code path even before you patch the library. Tools like Kong, Envoy, or custom Express/Fastify middleware can enforce this.- WAF rules — The tokens are cryptographically valid JWTs signed with the correct key. No WAF signature can distinguish a never-expiring token from a normal one at the network layer.
- Rotating signing keys — Key rotation invalidates old tokens, but only after the rotation window. It doesn't fix the root cause, and any token minted after rotation with the new key will still lack
expif the vulnerable code path persists. - Rate limiting — Throttling API requests doesn't address token lifetime. A never-expiring token used at normal rates is indistinguishable from a legitimate session.
The supporting signals.
| In-the-Wild Exploitation | None observed. Not listed in CISA KEV. No reports of active exploitation from any threat intelligence source as of 2026-10-18. |
|---|---|
| Proof-of-Concept | No public PoC. No repositories found on GitHub, pocindex.io, ExploitDB, or Nuclei templates for CVE-2026-110024. The bug is trivially reproducible by calling fastify.jwt.sign(payload, { algorithm: 'RS256' }) with a global expiresIn configured, but no weaponized tooling exists. |
| EPSS Score | Not yet scored. FIRST EPSS API returns empty data — expected for a same-day disclosure (2026-10-18). Given the library-level scope and no exploitation history, expect a low EPSS once scored (likely < 0.05, bottom quartile). |
| KEV Status | Not listed. No CISA Known Exploited Vulnerabilities entry. |
| CVSS Vector | CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:N — Network-reachable but high complexity (requires specific code path), low privileges (valid account needed), no availability impact. C:H and I:H reflect that a non-expiring token grants full user-level read/write access indefinitely. |
| Affected Versions | @fastify/jwt < 10.2.5 (all versions with the top-level sign/verify API). Versions ≥ 10.2.5 merge per-call options with global defaults correctly. |
| Fixed Version | 10.2.5 — commit 5475979 on fastify/fastify-jwt. No distro backports apply (npm package, single upstream). |
| Installed Base | ~600K–700K weekly npm downloads, 154 direct dependents on npm. Fastify itself exceeds 10M monthly downloads. This is a popular but not ubiquitous package — compare to jsonwebtoken at ~40M/week. |
| Scanning/Exposure | No Shodan/GreyNoise/Censys exposure data is relevant — this is an application-layer npm library, not a network-facing appliance. Detection is via SCA (Snyk, npm audit, Socket.dev, Dependabot, Renovate). |
| Credits | Reported by awdr74100. Fix by mcollina (Fastify core maintainer). Reviewed by UlisesGascon. Published via OpenJS Foundation security process. |
Sources.
Why this verdict
- Route decorators are safe — the recommended pattern is unaffected. The Fastify documentation and ecosystem convention is to use
request.jwtVerify()andreply.jwtSign()inside route handlers. These already merge options correctly. Only the top-levelfastify.jwt.sign()/fastify.jwt.verify()API — typically used in background services, CLI tooling, or custom token-issuance helpers — triggers the bug. This significantly narrows the exposed population. - Developer must use a specific code pattern to trigger. The bug requires (a) global temporal defaults configured, AND (b) per-call options passed to the top-level API. If either condition is absent, the vulnerability doesn't manifest. This isn't an attacker-triggered flaw — it's a silent developer-side misconfiguration the library enables.
- Impact is session persistence, not direct exploitation. The attacker doesn't gain new access — they retain access they already had (PR:L) or extend the life of a stolen token. There's no RCE, no privilege escalation, no data exfiltration beyond what the token's original scope already permitted. The harm is that token expiration stops working as a revocation boundary.
- No exploitation evidence compounds the narrowing. Zero PoCs, zero KEV, zero in-the-wild activity, no EPSS score yet. The vulnerability was disclosed today and there's no indication threat actors are aware of or interested in this specific code path.
- Role multiplier:
@fastify/jwtis an application-layer npm library. It is NOT canonically a high-value-role component (not an IdP, hypervisor, CA, PAM, or network edge). (a) In dev sandboxes and prototypes: negligible impact. (b) In typical API servers/microservices: session persistence at user/tenant level — the attacker maintains one user's access, not fleet-wide compromise. (c) In the highest-value scenario — a SaaS platform's auth token issuance — the blast radius is persistent user-level access, which is serious for compliance but does NOT reach domain takeover, fleet compromise, or supply-chain pivot. The floor remains MEDIUM.
Why not higher?
The vulnerability cannot reach HIGH because the attacker position is PR:L (requires existing valid account or token theft via a separate flaw), the affected code path is a minority pattern in the ecosystem (route decorators are safe), and the blast radius caps at persistent user-level session access — it does not escalate privileges, enable RCE, or compromise infrastructure. @fastify/jwt is an application library, not identity infrastructure; even in worst-case SaaS deployments the outcome is tenant-scoped, not fleet-scoped.
Why not lower?
The vulnerability should not be LOW because when the affected code path IS hit, the result is genuinely dangerous: tokens with no expiration ever. This breaks a fundamental security invariant. Any token theft incident becomes permanent access rather than time-bounded. For organizations subject to PCI DSS, SOC 2, or HIPAA session-timeout requirements, this is a compliance violation. The ~600K weekly downloads mean a meaningful absolute number of affected applications exist, even if the fraction using the vulnerable pattern is modest.
Crowdsourced verification payload.
Run this script on any host where the Fastify application is deployed, or in your CI pipeline. Execute from the application's root directory (where package.json lives). Requires node (any version) and read access to node_modules. Example: bash check_cve_2026_110024.sh /opt/myapp
#!/usr/bin/env bash
# check_cve_2026_110024.sh — Detect @fastify/jwt < 10.2.5 (CVE-2026-110024)
# Usage: bash check_cve_2026_110024.sh [/path/to/app/root]
# Exit codes: 0 = PATCHED, 1 = VULNERABLE, 2 = UNKNOWN
set -euo pipefail
APP_ROOT="${1:-.}"
PKG_JSON="$APP_ROOT/node_modules/@fastify/jwt/package.json"
if [ ! -f "$PKG_JSON" ]; then
echo "UNKNOWN — @fastify/jwt not found in $APP_ROOT/node_modules"
exit 2
fi
# Extract version from package.json
VERSION=$(node -e "try { console.log(require('$PKG_JSON').version) } catch(e) { console.log('error') }" 2>/dev/null)
if [ "$VERSION" = "error" ] || [ -z "$VERSION" ]; then
echo "UNKNOWN — could not parse version from $PKG_JSON"
exit 2
fi
echo "Detected @fastify/jwt version: $VERSION"
# Compare versions: vulnerable if < 10.2.5
REQUIRED="10.2.5"
SORTED_LOWEST=$(printf '%s\n%s' "$VERSION" "$REQUIRED" | sort -V | head -n1)
if [ "$VERSION" = "$REQUIRED" ] || [ "$SORTED_LOWEST" = "$REQUIRED" ]; then
echo "PATCHED — @fastify/jwt $VERSION >= $REQUIRED (CVE-2026-110024 fixed)"
exit 0
else
echo "VULNERABLE — @fastify/jwt $VERSION < $REQUIRED (CVE-2026-110024 applies)"
# Additional check: scan for top-level API usage
echo ""
echo "Scanning for potentially affected code patterns..."
HITS=$(grep -rn 'fastify\.jwt\.sign\|fastify\.jwt\.verify\|app\.jwt\.sign\|app\.jwt\.verify\|server\.jwt\.sign\|server\.jwt\.verify' "$APP_ROOT/src" "$APP_ROOT/lib" "$APP_ROOT/routes" 2>/dev/null || true)
if [ -n "$HITS" ]; then
echo "Found top-level API usage (review these for per-call options):"
echo "$HITS"
else
echo "No top-level fastify.jwt.sign/verify calls found in src/, lib/, routes/ — you may only use route decorators (which are safe)."
fi
exit 1
fi