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
- Run
pip show pyjwtin your project to see the installed version, including copies pulled in by other packages. - Or paste your lockfile into the checker: it lists every package with a known vulnerability, this one included.
- Or run
npx shippable-dev scan localhost:3000in 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.
- Upgrade
pyjwtto 2.14.0 or later, commit the lockfile and redeploy. - 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
- github.com/jpadilla/pyjwt/security/advisories/GHSA-pxh4-856f-4h89
- github.com/jpadilla/pyjwt/commit/af8181ca0bec5e6b372fbba9afbe23702b787ceb
- github.com/jpadilla/pyjwt/releases/tag/2.14.0
- github.com/advisories/GHSA-pxh4-856f-4h89
- github.com/jpadilla/pyjwt
Merged from GitHub Advisory Database, OSV. Last updated 25 Oct 2026. All vulnerabilities · RSS · JSON