>
back to archive
April 29, 202613 min read

The Empty String That Bypassed JWT Verification: GHSA-gmvf-9v4p-v8jc

A zero-length HMAC key in fast-jwt 6.2.3 lets an attacker forge any JWT and pass signature verification. Here's the bug, the bypass walked through end to end, and the four-line fix that closes it.

#CVE#JWT#fast-jwt#Auth Bypass#Cryptography#GHSA#Node.js

fast-jwt 6.2.3 accepted zero-length HMAC keys during signature verification. If your async key resolver ever returned an empty string or Buffer.alloc(0), an attacker could forge tokens with arbitrary claims and pass verification by signing with the same empty key. Published as GHSA-gmvf-9v4p-v8jc (Critical, CVSS 9.1), fixed in 6.2.4.

This post walks through the bug from the ground up: enough JWT background to follow along even if you've never poked at one, the exact code path that breaks, why empty keys end up in real applications, and the four-line patch that closes it.

Background, in plain language

If you've shipped a Node.js API in the last decade you've probably touched a JSON Web Token (JWT). A JWT is just three base64url-encoded chunks separated by dots: a header (algorithm and key id), a payload (claims like sub, role, exp), and a signature. The signature is the only thing standing between "the user is who they say they are" and "anyone on the internet can mint a token that says role: admin."

There are two flavors of JWT signature you'll see in practice:

  1. Symmetric (HMAC): the server signs and verifies with the same shared secret. Algorithms: HS256, HS384, HS512. The signature is HMAC-SHA256(secret, header + "." + payload). Anyone who knows the secret can forge tokens, so the secret never leaves the server.
  2. Asymmetric (RSA / ECDSA / EdDSA): the server signs with a private key and verifies with a public key. Algorithms: RS256, ES256, PS256, EdDSA. Forging tokens requires the private key.

The bug below lives entirely in the symmetric path. The attacker tricks the server into HMAC-verifying a token against an empty secret, then forges a token using that same empty secret. The HMAC matches because both sides used the same key (nothing). Auth bypass.

The reason this is even possible boils down to one fact: in JavaScript, Buffer.alloc(0) is truthy. The defensive guard if (!secret) that JWT libraries write early in their verification path catches null and undefined, but it does not catch a zero-length Buffer. That's the whole crack.

The bug, walked through step by step

fast-jwt lets you provide the verification key in several ways. You can hand it a string, a Buffer, a Node KeyObject, or, this is where it gets interesting, an asynchronous resolver function. The resolver pattern looks like this:

js
const verifier = createVerifier({
  key: async ({ header }) => {
    return await fetchKeyByKid(header.kid)
  }
})
 
verifier(token, (err, decoded) => { /* ... */ })

Why would a library let you supply the key as an async function instead of a static value? Because in any non-trivial system you don't have one secret. You have key rotation, multiple issuers, JWKS endpoints (JSON Web Key Set, a standardized JSON document at /.well-known/jwks.json that lists current public keys), and per-tenant keys. The header on each token has a kid (key id) field that tells you which key to verify with. The resolver looks up the right key for that kid.

That kid field is sent by the client, in the JWT itself. The attacker controls it. Hold that thought.

Now, the bug is in what fast-jwt did when that resolver returned an empty string "" or a zero-length buffer (Buffer.alloc(0)). In src/verifier.js, the prepareKeyOrSecret function takes whatever the resolver returns, normalizes it to a Buffer, and passes it to Node's crypto.createSecretKey:

js
function prepareKeyOrSecret(key, isSecret) {
  if (typeof key === 'string') {
    key = Buffer.from(key, 'utf-8')
  }
  return isSecret ? createSecretKey(key) : createPublicKey(key)
}

There is no length check. Whatever comes out of the resolver, that's what gets handed to the crypto layer.

Here's the part that surprised me: createSecretKey(Buffer.alloc(0)) does not throw. Node's crypto module happily accepts a zero-length buffer and gives you back a KeyObject representing the empty key. From the Node docs and source, the rationale is that lower-level primitives don't enforce key-length policy because that's an algorithm-level concern. RFC 2104 (HMAC) technically allows arbitrary key lengths, including zero, even though it strongly recommends keys at least as long as the hash output (32 bytes for SHA-256). Node treats this as the caller's problem.

So fast-jwt ends up with a valid KeyObject that wraps zero bytes. From there, it derives the allowed algorithms based on the key's type. Since the key is symmetric (HMAC), the allowed list becomes ['HS256', 'HS384', 'HS512']. Then fast-jwt proceeds to compute HMAC-SHA256(empty_key, token_body) and compares it to the signature on the incoming token.

Now the attacker side. An attacker who can guess that the server might end up with an empty key for some kid (and we'll see in a moment why that's a very easy guess) just signs their own token with an empty HMAC secret, using any JWT library that will let them:

js
const jwt = require('jsonwebtoken')
 
const token = jwt.sign(
  { sub: 'admin', role: 'admin' },
  '',                                       // empty secret
  { algorithm: 'HS256', noTimestamp: false, expiresIn: '1h' }
)
 
console.log(token)
// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhZG1pbiIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTc...

Send that token at the server. The server's resolver runs, looks up the unknown kid, doesn't find it, returns '' because of a fallback. fast-jwt HMAC-verifies the token against ''. The HMAC matches, because the attacker computed the signature using the same empty key. Verification passes. The handler runs with req.user.role === 'admin'.

End-to-end trust chain:

  1. Attacker forges a token claiming { sub: 'admin', role: 'admin' } and signs it with the empty string as secret.
  2. Token reaches the server. fast-jwt parses the header, sees alg: HS256 and kid: <some attacker-chosen value>.
  3. Server's resolver runs keys[header.kid] || ''. The lookup misses. Returns ''.
  4. prepareKeyOrSecret converts '' to Buffer.alloc(0), calls createSecretKey(Buffer.alloc(0)). Returns a valid KeyObject for the empty key.
  5. fast-jwt computes HMAC-SHA256(empty_key, header + '.' + payload). Compares to the signature on the token. Match.
  6. Verification returns success. Application code trusts decoded.role === 'admin'.

Full auth bypass. No knowledge of any real secret. No CPU effort, no oracle, no timing attack, no rainbow table. Network input only.

Why empty-string keys exist in real applications

This is the part that turned the bug from "interesting edge case" into "Critical." Nobody sets out to configure their JWT verifier with an empty secret. The bug isn't that someone wrote key: '' directly. The bug is that the empty key arrives as a result, from a resolver whose code looks completely defensible at code review time. Three patterns I've seen in real codebases:

js
// JWKS-style multi-tenant resolver
const verifier = createVerifier({
  key: async ({ header }) => keys[header.kid] || ''
})
js
// Environment-variable fallback
const verifier = createVerifier({
  key: async () => process.env.JWT_SECRET || ''
})
js
// Defensive empty-string fallback to avoid crash on missing kid
const verifier = createVerifier({
  key: async ({ header }) => {
    const k = await db.getKeyForKid(header.kid)
    return k ?? ''
  }
})

All three of these turn into auth bypasses on fast-jwt 6.2.3 when the lookup misses. The reason they exist isn't bad engineering, it's Node.js culture. The || '' and ?? '' patterns are a reflex baked into the ecosystem: defensive programming to avoid crashes when a value is missing. Throwing on a missing key would be the safer choice cryptographically, but it's not the choice most engineers reach for, because they've spent years getting yelled at by Node when something deeply nested in their code dereferences undefined.

This matters because of who's in the chain. The JWT library is the trust boundary. The library has total context: it knows what algorithm it's about to run, what the key needs to look like, and what the consequences of a weak key are. The application code does not. The application code is just shuffling values from a database into a callback return. Asking application developers to encode "this string must not be empty because otherwise the HMAC verifier will accept attacker forgeries" into every fallback expression is a losing strategy. Most of them will not, because that knowledge lives inside the library, not inside their head.

The clearest analogy is SQL parameter binding. In principle, an application could sanitize its own SQL input before constructing a query string. In practice, libraries that don't enforce parameter binding ship arbitrary SQL injection holes into every consumer that forgets, and we treat that as the library's fault. Same logic here: a library that accepts zero-length cryptographic keys ships auth-bypass holes into every consumer with a fallback expression in their resolver. That's a library-level bug.

Why Buffer.alloc(0) slips through

Most JWT libraries already have a guard like this near the top of their verification path:

js
if (!secretOrPublicKey) {
  return done(new JsonWebTokenError('secret or public key must be provided'));
}

This catches null and undefined because they're falsy. It does not catch Buffer.alloc(0), because !Buffer.alloc(0) is false. A zero-length Buffer is truthy in JavaScript:

js
> Boolean(Buffer.alloc(0))
true
> Boolean('')
false
> Boolean(null)
false
> Boolean(undefined)
false

The reason Buffer.alloc(0) is truthy is that it's a Buffer object, and all objects in JavaScript are truthy. Even an empty array ([]) and an empty object ({}) are truthy. The only falsy values in JavaScript are false, 0, "", null, undefined, and NaN. A Buffer with zero bytes is still an object, so it's truthy. This is the kind of fact you internalize the first month you write Node and then forget about, until it costs you an auth bypass.

So '' would actually be caught by the existing !secret guard (because !"" is true). But Buffer.alloc(0) slips right through. And here's why this matters in practice: many JWKS-style resolvers normalize their return values to Buffers before handing them to the JWT library. Code like:

js
const k = await db.getKeyForKid(header.kid)
return Buffer.from(k || '', 'utf-8')   // returns Buffer.alloc(0) when k is missing

This kind of normalization is encouraged by every Node tutorial that says "always work with Buffers when dealing with binary data." The author is doing the right thing for binary safety, and accidentally creating an auth-bypass primitive.

The trigger conditions

For the bug to fire, all five of these must be true:

  1. Async key callback. The application uses createVerifier({key: async (decoded) => ...}). Synchronous keys are usually constants (you set the secret once at startup), so an empty secret would be obvious. Async callbacks compute the key at request time, with the request as input, which is where attacker influence enters.
  2. Callback returns '' or Buffer.alloc(0). Other empty/missing return values (null, undefined) do not trigger this issue, because they're falsy and get caught by the existing guard.
  3. HMAC algorithms allowed. This is the default fast-jwt configuration, so most consumers are exposed unless they've explicitly restricted to asymmetric algorithms.
  4. Attacker signs a token with an empty string. Five lines of code in any JWT library. No special knowledge required.
  5. Other token claims still validate. Signature verification is the only gate the empty key bypasses. exp, iat, iss, aud checks still run. But since the attacker is forging the entire token, they can pick any future exp they want, so this restriction doesn't slow them down.

CVSS vector decoded

Published Critical, CVSS 9.1, vector AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N:

  • AV:N (Attack Vector: Network), the attack is launched over the network.
  • AC:L (Attack Complexity: Low), no special conditions need to be met by the attacker.
  • PR:N (Privileges Required: None), unauthenticated.
  • UI:N (User Interaction: None), no victim has to click anything.
  • S:U (Scope: Unchanged), the impact is bounded to the vulnerable component.
  • C:H (Confidentiality: High), the attacker can read whatever the forged identity is allowed to read.
  • I:H (Integrity: High), the attacker can write whatever the forged identity is allowed to write.
  • A:N (Availability: None), this isn't a DoS.

The fix

Four lines, before createSecretKey:

diff
 function prepareKeyOrSecret(key, isSecret) {
   if (typeof key === 'string') {
     key = Buffer.from(key, 'utf-8')
   }
 
+  if (isSecret && key.length === 0) {
+    throw new TokenError(TokenError.codes.invalidKey, 'The key cannot be an empty string or buffer.')
+  }
 
   return isSecret ? createSecretKey(key) : createPublicKey(key)
 }

Two design choices in this patch are worth pointing out, because they're the difference between "fix that prevents the bug" and "fix that prevents the bug class":

Why the isSecret gate. The check only runs when we're about to build a symmetric key. That's because asymmetric verification (RS256, ES256, PS256, EdDSA) goes through createPublicKey, which already rejects malformed PEMs. A zero-length buffer can't pass as a valid public key, so the asymmetric path is naturally guarded. Adding a length check there too would be redundant. Targeting only the symmetric path keeps the patch surgical.

Why throw, not return null or fall back. Throwing a TokenError is the right error type because fast-jwt already throws TokenError for other invalid-key conditions, and those errors propagate correctly up to the verifier callback. If the patch returned null or attempted a "best-effort" recovery, an application could end up with a half-constructed verifier that fails open in some other code path. Throwing makes the failure visible, immediate, and recoverable: the application gets an error and can decide whether to log, retry with a different key, or reject the request.

The fix shipped with 194 lines of new tests in verifier.spec.js covering every combination: empty string, empty buffer, Buffer.alloc(0), async resolver that returns each. The test file also re-asserts that valid keys still pass.

The full attack matrix tested before disclosure was 18 cells: × × . Every cell with a zero-length symmetric key was vulnerable pre-patch. Every cell is rejected post-patch. Asymmetric and non-empty symmetric paths are untouched.

What to do if you're affected

If you maintain a Node.js JWT library, search your codebase for paths where a key flows from an async resolver into createSecretKey, createHmac, or any HMAC verification primitive without an explicit zero-length check. The !secret falsy guard isn't enough; Buffer.alloc(0) is truthy. The fix is one comparison: if (key.length === 0) throw.

If you're a consumer of fast-jwt, upgrade to 6.2.4 or later.

If you're a consumer of any JWT library with async key resolvers, audit your resolver for empty-value fallbacks. Don't return '' or Buffer.alloc(0) on lookup misses. Throw, or return a known-invalid sentinel that your library will reject.

Defense in depth at the configuration level: pin the verifier to the algorithm class you actually use. If your tokens are RSA-signed, set allowedAlgorithms: ['RS256'] (or whatever you use). Even if an empty symmetric key sneaks through somehow, the verifier won't accept the HS256 token your attacker sends because HS256 isn't on the allow-list. This is one of those config flags that almost no one sets and almost everyone should.

The broader pattern: every cryptographic primitive that takes a key (HMAC, AES, signatures, MAC verification) should reject zero-length keys at the library boundary. Node.js itself doesn't enforce this at the createSecretKey / createHmac level, on the theory that downstream libraries should. Most don't. node-jose got it right (their algorithms getter returns [] for any key shorter than the hash output, which makes the algorithm list empty and verification automatically fails). Expect to keep seeing this bug class in JWT libraries across languages until the lower-level primitives enforce it.

Pointers