Neutron, our AI engine, scored 97.1% on UC Berkeley's CyberGym benchmark. Learn more

Deep Dive

Deep Dive

python-jose JWT Forgery With an Empty HMAC Key (CVE-2026-120459)

python-jose through 3.5.0 accepts an empty HMAC secret, so a forged JWT verifies when the application's key resolves to empty; no fixed release.

TL;DR

  • python-jose accepts a zero-length HMAC key. When an application's HS256, HS384 or HS512 verification key resolves to an empty value, an attacker can compute the signature with that empty key and have forged claims accepted.
  • Every release through 3.5.0 is affected. The newest release is still 3.5.0, published on 2025-05-28, and no fixed version exists as of 2026-11-09.
  • The CVE record rates it CVSS v4.0 9.1 Critical and CVSS v3.1 7.4 High. The reporter's PoC is public in issue #398; no exploitation is known, and it is not in CISA KEV as of 2026-11-09.
  • Reject empty keys in your own code or move to a library that does. PyJWT 2.15.1 refuses an empty HMAC key with InvalidKeyError.

Am I affected?

All of these must hold:

  • You verify JSON Web Tokens with python-jose version 3.5.0 or earlier, using HS256, HS384 or HS512.
  • The verification key can resolve to an empty value: an empty string, empty bytes, or an oct JWK whose k member is empty.
  • Your application trusts claims from a token that an attacker can supply.

A fixed, non-empty secret is not exploitable. The flaw matters where key material is assembled at runtime. Examples are an environment variable with an empty default, a JWKS endpoint that returns an empty k, or a database column that was never populated.

The reporter's issue also notes that jwt.decode() defaults algorithms to None, which skips the algorithm allowlist. That is a separate weakness, not the empty-key flaw, but an application can combine both and let the attacker choose the algorithm.

Library Affected versions Fixed version
python-jose 0 through 3.5.0 None as of 2026-11-09

Check what is installed:

pip show python-jose
pip freeze | grep -i jose
grep -rn "python-jose" requirements*.txt pyproject.toml Pipfile.lock poetry.lock 2>/dev/null

Then find every verification call and the source of its key:

grep -rn "jwt.decode\|jws.verify" --include="*.py" .

How do I fix it?

No fixed python-jose release exists. The practical fix is to stop passing empty key material to the library, and to migrate to a maintained library that refuses it.

  1. Add a guard at every verification site so an empty string, empty bytes or an oct JWK with an empty k is rejected before jwt.decode() runs:
# Reject empty HMAC key material before python-jose sees it.
from jose.exceptions import JWKError

def require_nonempty_hmac_key(key):
    if isinstance(key, (str, bytes)) and len(key) == 0:
        raise JWKError("HMAC key must not be empty")
    if isinstance(key, dict) and key.get("kty") == "oct":
        if not key.get("k"):
            raise JWKError("oct JWK k must not be empty")
    return key
  1. Pass an explicit algorithm allowlist on every call, even though it is not the empty-key flaw itself:
claims = jwt.decode(token, require_nonempty_hmac_key(key), algorithms=["HS256"])
  1. Migrate to PyJWT, which rejects the empty key at key preparation. The check is in HMACAlgorithm.prepare_key and raises InvalidKeyError("HMAC key must not be empty.").
import jwt as pyjwt

claims = pyjwt.decode(token, key, algorithms=["HS256"])
  1. If the key can come from a JWKS document, validate the document when it is fetched and reject any oct key with an empty k. Do not wait for the verification call to fail.

Validating the JWKS document is worth doing even after migration. A JWKS endpoint that returns {"kty":"oct","k":""} is a configuration bug on its own, and any verifier that accepts it as key material fails open. Reject the document and alert when a symmetric key has an empty k.

If you cannot change the code immediately, rotate the affected verification key to a strong random value. That closes the exposure only if every deployment receives the new value; an empty key remains exploitable wherever the configuration still resolves to empty.

What is the root cause?

python-jose validates the type of an HMAC key but never its length, and the JWK path turns an empty k member into empty bytes. The HMAC construction then uses that empty key, whose signature anyone can compute.

In jose/backends/native.py at 3.5.0, HMACKey.__init__ accepts any string or bytes value:

def __init__(self, key, algorithm):
    if algorithm not in ALGORITHMS.HMAC:
        raise JWKError("hash_alg: %s is not a valid hash algorithm" % algorithm)
    self._algorithm = algorithm
    self._hash_alg = self.HASHES.get(algorithm)

    if isinstance(key, dict):
        self.prepared_key = self._process_jwk(key)
        return

    if not isinstance(key, str) and not isinstance(key, bytes):
        raise JWKError("Expecting a string- or bytes-formatted key.")

    if isinstance(key, str):
        key = key.encode("utf-8")

    if is_pem_format(key) or is_ssh_key(key):
        raise JWKError(
            "The specified key is an asymmetric key or x509 certificate and"
            " should not be used as an HMAC secret."
        )

    self.prepared_key = key

There is no len(key) == 0 check. _process_jwk decodes the k member with base64url_decode, and that function returns an empty byte string for an empty input:

def _process_jwk(self, jwk_dict):
    if not jwk_dict.get("kty") == "oct":
        raise JWKError("Incorrect key type. Expected: 'oct', Received: %s" % jwk_dict.get("kty"))

    k = jwk_dict.get("k")
    k = k.encode("utf-8")
    k = bytes(k)
    k = base64url_decode(k)

    return k

CryptographyHMACKey in jose/backends/cryptography_backend.py repeats the same constructor logic and has the same gap. Both backends then compute hmac.new(self.prepared_key, msg, self._hash_alg) and compare the result with hmac.compare_digest or h.verify. With an empty key, that computation is deterministic and public, so the comparison succeeds for a signature the attacker produced.

The JWK path is what makes the flaw reachable from configuration rather than from an obvious empty string. jws._get_keys() treats a mapping with kty as an individual JWK, jwk.construct() returns the HMAC key class for an HS algorithm, and the empty k becomes b"" before verification.

jws._verify_signature() reads alg from the token header, builds the key for each candidate and calls key.verify(). With an empty key that call returns true for the attacker's signature, and the rest of the JWT validation proceeds on claims the attacker chose.

How is it exploited?

The attacker needs a token endpoint or any request that carries a JWT, and knowledge or a good guess that the deployment's key is empty:

  1. The attacker finds an application that verifies HS256 tokens with python-jose and whose key resolves to empty, for example because JWT_SECRET is unset and the code passes os.environ.get("JWT_SECRET", "").
  2. The attacker builds a token with the claims the application trusts, such as {"sub": "admin", "role": "admin"}, and signs it with an empty HMAC key.
  3. The attacker sends the token. python-jose constructs HMACKey(b"", "HS256"), computes the same signature and accepts the token.
  4. The application acts on the forged claims, because it never sees a key problem.

The reporter's proof of concept is public in issue #398 and runs as written:

import jose.jwt as jwt

# This succeeds and should raise an error.
token = jwt.encode({"admin": True}, "", algorithm="HS256")
jwt.decode(token, "", algorithms=["HS256"])  # Returns {"admin": True}

The same works with the JWK form, which is the shape a JWKS-driven deployment uses:

jwt.decode(token, {"kty": "oct", "k": ""}, algorithms=["HS256"])

How does the attack flow?

An application that lets its HMAC verification key resolve to empty hands python-jose a zero-length secret, and python-jose signs and verifies with it instead of rejecting the configuration.

flowchart LR
    A["App key resolves to empty"] --> B["HMACKey accepts zero-length key"]
    B --> C["Attacker computes HMAC with empty key"]
    C --> D["jwt.decode compares signatures"]
    D --> E["Forged claims accepted"]

Timeline of CVE-2026-120459: python-jose 3.5.0 shipped on May 28, 2025, the public issue was opened on March 8, 2026, and the CVE was published on November 2, 2026
Figure 1: The flaw was reported in March 2026 and received a CVE in November 2026, with no fixed release in between. Source: the GitHub issue, PyPI release history and the CVE record, checked November 9, 2026.

What can an attacker do?

With a forged token accepted, the attacker takes on every claim the application trusts. That can include:

  • Any user identity, including an administrator, when the application authorizes from token claims.
  • Any tenant or organization identifier the application uses for data scoping.
  • Extended expiry, because the attacker chooses the exp claim.
  • Any permission or role the application reads from the token.

The attacker cannot recover a real signing key, decrypt other tokens, or sign tokens that a correctly configured deployment accepts. The impact depends on what the application does with the claims, and on whether the empty-key condition is present.

Service-to-service tokens matter here too. A forged token for an internal API can cross a trust boundary that never sees a user. An empty key in a background service can therefore expose more than a login endpoint would.

Any request that carries a bearer token is a candidate, including CI jobs and internal service calls. A deployment with a strong, non-empty secret and an explicit algorithm allowlist is not exploitable through this flaw.

Why does the fix work?

An HMAC key of length zero has no secret: anyone can compute the same MAC, so a verifier that receives one authenticates nothing. The fix is an application-side length check or a move to a library that performs one.

The check a fixed release must add is small. In both backends, immediately after the type check:

     if not isinstance(key, str) and not isinstance(key, bytes):
         raise JWKError("Expecting a string- or bytes-formatted key.")

+    if len(key) == 0:
+        raise JWKError("HMAC key must not be empty.")
+
     if isinstance(key, str):
         key = key.encode("utf-8")

The JWK path needs its own check after base64url_decode, because an empty k never reaches the string branch.

PyJWT performs the same length check and was verified locally on 2026-11-09 with an HS256 encode and decode. That makes migration a fix for this flaw, not only a change of library.

The python-jose issue lists four further findings in the same report, including DER key algorithm confusion and the algorithms=None default. Migrating addresses the empty-key flaw and avoids depending on an unmaintained project for the others.

How do I check if I was compromised?

A forged token looks like a valid token, so the signature check cannot tell you it was forged. Look for the configuration condition first, then for account activity that the real user did not perform.

  1. Confirm whether any deployment can pass an empty key. Search configuration defaults and the key-loading code, and query the JWKS endpoint you use.
  2. Decode the tokens your service accepted during the exposure window and look for claims that do not match the account, such as an unexpected sub, role or tenant. The payload is readable without the key.
# Show a token's header and claims without verifying it.
python - <<'PY'
import base64, json, sys
token = sys.stdin.read().strip()
for part in token.split(".")[:2]:
    part += "=" * (-len(part) % 4)
    print(json.dumps(json.loads(base64.urlsafe_b64decode(part)), indent=2))
PY
  1. Search authentication logs for tokens with an unusual iat, exp or sub for the account. Then look for administrative actions from sessions that cannot be tied to a real login.
  2. Rotate the verification key to a strong random value and invalidate existing sessions. If the key was empty, every token signed during that period must be treated as untrusted.
  3. Move to PyJWT or add the guard, then re-test that an empty key is rejected.

A failed forgery attempt can leave no trace beyond the request itself, because a rejected signature and a forged one follow the same code path until the comparison.

Timeline

  • 2025-05-28: python-jose 3.5.0 is released, the newest release as of 2026-11-09.
  • 2026-03-08: the reporter opens issue #398 with the empty-key finding and a proof of concept.
  • 2026-11-02: VulnCheck publishes CVE-2026-120459.

FAQ

Is my application affected if my HMAC secret is a strong random value?

No. The flaw requires the verification key to resolve to an empty value. A strong, non-empty secret makes the forgery computation impossible for an attacker, even on an affected python-jose release.

Does passing algorithms=["HS256"] fix the empty-key flaw?

No. The allowlist limits which algorithms the verifier accepts, but it does not check the key length. A token signed with HS256 and an empty key still verifies when the application passes an empty key and allows HS256.

Is there a fixed python-jose release?

No. The newest release is still 3.5.0 from 2025-05-28, and the CVE record names no fixed version. Use the application guard or migrate to a maintained library.

Does PyJWT have the same problem?

No. PyJWT 2.15.1 rejects an empty HMAC key with InvalidKeyError("HMAC key must not be empty."). That was verified locally on 2026-11-09 with an HS256 encode and decode.

Does rotating the key help?

Yes, if every deployment receives the new value. Rotating to a strong random secret removes the empty-key condition that the forgery depends on, and it also invalidates tokens signed with the old key. An empty key remains exploitable wherever the configuration still resolves to empty, so pair the rotation with the guard.

Can an attacker exploit this without knowing the key is empty?

The attacker needs the empty-key condition to hold. Some deployments make that guessable, for example an unset environment variable with an empty default or a JWKS entry with k: "". Once the condition holds, the attacker computes the signature locally and needs no further access.

Find services using python-jose with Ostorlab

Ostorlab's software composition analysis and SBOM inventory lists the Python packages and versions your services resolve. That lets you find the applications that still depend on python-jose and route them through the empty-key check or a migration.

References

  • VulnCheck advisory, "python-jose through 3.5.0 Authentication Bypass via Empty HMAC Key" (2026-11-02): https://www.vulncheck.com/advisories/python-jose-through-3.5.0-authentication-bypass-via-empty-hmac-key (checked November 9, 2026)
  • CVE record CVE-2026-120459 (2026-11-02): https://www.cve.org/CVERecord?id=CVE-2026-120459 (checked November 9, 2026)
  • python-jose issue #398, "Security: DER key algorithm confusion, empty HMAC key, and timing side-channels" (2026-03-08): https://github.com/mpdavis/python-jose/issues/398 (checked November 9, 2026)
  • python-jose 3.5.0, jose/backends/native.py (2025-05-28): https://github.com/mpdavis/python-jose/blob/3.5.0/jose/backends/native.py (checked November 9, 2026)
  • python-jose 3.5.0, jose/backends/cryptography_backend.py (2025-05-28): https://github.com/mpdavis/python-jose/blob/3.5.0/jose/backends/cryptography_backend.py (checked November 9, 2026)
  • PyJWT 2.15.1, HMACAlgorithm.prepare_key (checked November 9, 2026): https://github.com/jpadilla/pyjwt/blob/2.15.1/jwt/algorithms.py
  • PyPI project page for python-jose, release history (checked November 9, 2026): https://pypi.org/project/python-jose/
  • pypistats, python-jose weekly downloads (checked November 9, 2026): https://pypistats.org/packages/python-jose