# GHSA-pxh4-856f-4h89: PyJWT: HS256/384/512 verify accepts empty HMAC key via oct JWK (PyJWK/PyJWKClient) enabling token forgery


- **Severity:** Not scored
- **Exploited in the wild:** Not known
- **Fix available:** Yes
- **Published:** 22 Oct 2026

## What is GHSA-pxh4-856f-4h89?

GHSA-pxh4-856f-4h89 is a vulnerability in pyjwt, published on 22 Oct 2026. 

### Official description

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

## Which versions are affected by GHSA-pxh4-856f-4h89?

2 open source packages ship an affected version, including pyjwt.


| Package | Ecosystem | Affected | Fixed in |
| --- | --- | --- | --- |
| `pyjwt` | PyPI | All versions | 2.14.0 |
| `pyjwt` | PyPI | All versions | 2.14.0 |

## How do I fix GHSA-pxh4-856f-4h89?

Upgrade pyjwt to 2.14.0 or later.

## Is GHSA-pxh4-856f-4h89 being exploited?

There is no known exploitation of GHSA-pxh4-856f-4h89 in the wild and no public exploit code.

## Fix prompt for AI coding agents

```text
Check this codebase for GHSA-pxh4-856f-4h89 and fix it if it is affected.

What it is: GHSA-pxh4-856f-4h89 is a vulnerability in pyjwt, published on 22 Oct 2026. 
Exploitation: There is no known exploitation of GHSA-pxh4-856f-4h89 in the wild and no public exploit code.

Affected packages and fixed versions:
- pyjwt (PyPI): affected all versions; fixed in 2.14.0
- pyjwt (PyPI): affected all versions; fixed in 2.14.0

Task:
1. Search the dependency manifests and lockfiles (for example package.json, package-lock.json, requirements.txt, poetry.lock, uv.lock, pom.xml, build.gradle, go.mod, Cargo.lock, Gemfile.lock) and any Dockerfiles for the affected packages, including transitive dependencies.
2. If an affected version is in use, upgrade it to the fixed version for its release line with the project's package manager, so the lockfile is regenerated. Prefer the smallest upgrade that fixes it.
3. If there is no fix, or the upgrade would break the build, do not guess: explain the risk and the safest workaround instead.
4. Run the tests and summarise what changed and why.
5. If nothing in the project is affected, say so and change nothing.

Reference: https://insights.sbomify.com/GHSA-pxh4-856f-4h89/ (Markdown: https://insights.sbomify.com/GHSA-pxh4-856f-4h89/index.md)
```

## Sources

- OSV: https://osv.dev/vulnerability/GHSA-pxh4-856f-4h89

---
From sbomify Insights: https://insights.sbomify.com/GHSA-pxh4-856f-4h89/
Summaries are generated and may contain mistakes; the official description above is the record.
