Live › vulnerability › GHSA-pxh4-856f-4h89
Go to dashboardPyJWT accepts an HMAC key that is an empty string when it is loaded from a JWK (kty=oct with k=""), so tokens signed with an empty key verify. An unauthenticated attacker can forge tokens with arbitrary claims such as role=admin on any endpoint that verifies through PyJWK, PyJWKSet or PyJWKClient with such a key.
Summary written from the sources below. Check the originals before acting.
First seen by InfraStack 2026-10-23 19:11 UTC. All times on this page are UTC.
| Product | Ecosystem | Affected | Fixed in |
|---|---|---|---|
| pyjwt | pypi | <= 2.13.0 | 2.14.0 |
Look for JWKS entries with kty=oct and an empty k value, served by your IdP or loaded from config, for example: curl -s <jwks_url> | grep "k":""
Check the installed version: pip show pyjwt (affected if 2.13.0 or lower)
Search for PyJWKClient, PyJWKSet or PyJWK use alongside HS256/HS384/HS512 verification
2.14.0 or laterEach row is the first time that source carried this signal. The analyst’s rows are its own findings.
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.
PyJWT (PyPI package pyjwt) <= 2.13.0, current master at commit
7144e4534c34810f4525dc4578a32addd8212cff. The string/bytes secret path is
already guarded in 2.13.0; the JWK/JWKS path is not.
Unauthenticated. Any party who can reach a JWT-verifying endpoint of an
application that verifies via a PyJWK / PyJWKSet / PyJWKClient whose oct
key has an empty k can forge tokens with arbitrary claims.
Preconditions: the verifier resolves the HMAC key from a JWK/JWKS whose oct
entry carries an empty k. This occurs when the signing secret is sourced from
an unset environment variable / unset config row / key-finder fallback that
base64url-encodes b"" into the published JWKS, or when the application trusts a
JWKS containing such an entry. The equivalent unset-secret case for the
string-secret API (jwt.decode(token, key="")) is already rejected in 2.13.0;
the JWK route is the remaining reachable surface for the same misconfiguration.
jwt/algorithms.py, HMACAlgorithm.from_jwk (line 380):
@staticmethod
def from_jwk(jwk: str | JWKDict) -> 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"]) # <-- empty k -> b"", no rejection
Contrast the sibling prepare_key, which DID get the guard (lines 325-329):
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:
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.
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.
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):
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):
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": }),
jsonify({: , : ,
: claims.get(), : claims.get()})
__name__ == :
app.run(port=)
forge.py (attacker; no secret knowledge):
This page as .md · .json · in your agent: get_signal("GHSA-pxh4-856f-4h89")
GitHub Advisory Database, CC-BY 4.0. Posts quoted belong to their authors.