high · 7.4 Patched

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

GHSA-pxh4-856f-4h89 · published 22 Oct 2026

How serious is it

  • Severity: high, CVSS score 7.4 out of 10.
  • No exploit prediction (EPSS) score yet.
  • Not on the CISA list of exploited vulnerabilities.
  • No public exploit in Exploit-DB.

Is your app affected by GHSA-pxh4-856f-4h89?

Paste this prompt into Claude Code, Cursor or any AI coding tool: it runs a free Shippable scan of your project and fixes what it finds. Built with Lovable, Bolt or v0? Scan your live app. Or upload your package file.

Who is affected

  • pyjwt (PyPI) <= 2.13.0

How to check

  1. Run pip show pyjwt in your project to see the installed version, including copies pulled in by other packages.
  2. Or paste your lockfile into the checker: it lists every package with a known vulnerability, this one included.
  3. Or run npx shippable-dev scan localhost:3000 in your project folder. It reads your lockfile and flags this vulnerability along with every other known one.

How to fix

Copies a prompt for Claude Code, Cursor or Codex: it checks your lockfiles for this and upgrades it.
  1. Upgrade pyjwt to 2.14.0 or later, commit the lockfile and redeploy.
  2. If a vulnerable version was live, check your logs and rotate the secrets that server could read.

The advisory

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

Check your whole app

Dependencies are only half of it. Free and read-only: before you push, run npx shippable-dev scan localhost:3000 in your project folder, no account needed. For your live app, you prove it's yours with a one-line tag first.

References

Merged from GitHub Advisory Database, OSV. Last updated 25 Oct 2026. All vulnerabilities · RSS · JSON