---
id: "GHSA-pxh4-856f-4h89"
kind: "vulnerability"
aliases: ["GHSA-pxh4-856f-4h89"]
title: "PyJWT: HS256/384/512 verify accepts empty HMAC key via oct JWK (PyJWK/PyJWKClient) enabling token forgery"
severity: "high"
cvss: 7.400000095367432
epss: null
exploited: false
status: "open"
category: "package"
products: [{"name": "pyjwt", "fixed": ["2.14.0"], "affected": "<= 2.13.0", "ecosystem": "pypi"}]
ecosystems: ["pypi"]
tags: ["cwe-347", "flask", "jwks", "package", "pyjwt", "python", "self-hosted"]
not_affected_if: ["asymmetric-only-verification", "managed-hosting", "non-empty-hmac-secret", "non-jwk-key-loading"]
published_at: "2026-10-22T15:48:04+00:00"
first_seen_at: "2026-10-23T19:11:32+00:00"
updated_at: "2026-10-23T19:56:17+00:00"
sources: [{"source": "ghsa", "url": "https://github.com/advisories/GHSA-pxh4-856f-4h89", "first_seen_at": "2026-10-23T19:11:32+00:00"}]
---

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

PyJWT 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, check and fix written by a model from the sources; labelled as such._

## Affected

- pypi:pyjwt affected <= 2.13.0 fixed 2.14.0

## How to check

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

## Fix

Upgrade PyJWT to 2.14.0 or later.
Until then, make sure the HMAC signing secret is never empty (fail startup if the env var is unset) and do not trust JWKS entries with an empty k.

## Timeline

- 2026-10-23T19:11:32+00:00 ghsa https://github.com/advisories/GHSA-pxh4-856f-4h89

## From the sources

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

```pyt

---

_Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai_
