Repository navigation
chore(deps): update dependency pyjwt to v2.15.0 [security] - #770
Open
renovate[bot] wants to merge 1 commit into
Open
renovate[bot] wants to merge 1 commit into
renovate[bot] wants to merge 1 commit into
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Note
This PR body was truncated due to platform limits.
This PR contains the following updates:
==2.13.0→==2.15.0Configuration
📅 Schedule: (UTC)
🚦 Automerge: Enabled.
♻ 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.
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-suppliedoptionsdict in place wheneververify_signatureis falsy, addingverify_exp/verify_nbf/verify_iat/verify_aud/verify_iss/verify_sub/verify_jtikeys directly onto that object. If application code reuses the sameoptionsdict across calls (a config object, a module-level constant, a wrapper'sself.options), a later call that explicitly setsverify_signature=Truefor a full, normal verification silently inherits the leftoververify_exp=Falseetc. from an earlierverify_signature=Falsecall 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 fromdecode_complete(), which backs bothjwt.decode()andjwt.decode_complete()):No
dict(options)copy occurs before these writes, unlike the pre-2025-03-05 code (see #743's fix,options = dict(options or {})). Confirmed viagh api repos/jpadilla/pyjwt/commits/29fbfc36that #1045's diff removes that line. Confirmed still present on currentmainvia a live fetch ofjwt/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:Reproduced deterministically across 2 independent process runs.
Impact
Preconditions: the calling application must reuse a mutable
optionsdict object across two or moredecode()/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 freshoptionsdict 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_optionsis covered: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 mutableoptionsmapping was modified whenverify_signaturewas 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:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:H/A:NReferences
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-suppliedoptionsdict in place wheneververify_signatureis falsy, addingverify_exp/verify_nbf/verify_iat/verify_aud/verify_iss/verify_sub/verify_jtikeys directly onto that object. If application code reuses the sameoptionsdict across calls (a config object, a module-level constant, a wrapper'sself.options), a later call that explicitly setsverify_signature=Truefor a full, normal verification silently inherits the leftoververify_exp=Falseetc. from an earlierverify_signature=Falsecall 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 fromdecode_complete(), which backs bothjwt.decode()andjwt.decode_complete()):No
dict(options)copy occurs before these writes, unlike the pre-2025-03-05 code (see #743's fix,options = dict(options or {})). Confirmed viagh api repos/jpadilla/pyjwt/commits/29fbfc36that #1045's diff removes that line. Confirmed still present on currentmainvia a live fetch ofjwt/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:Reproduced deterministically across 2 independent process runs.
Impact
Preconditions: the calling application must reuse a mutable
optionsdict object across two or moredecode()/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 freshoptionsdict 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_optionsis covered: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 mutableoptionsmapping was modified whenverify_signaturewas 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:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:H/A:NReferences
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](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
kidpreviously 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
masterin commitba4853a.PyJWKClientnow applies a30-second cooldown after successful JWKS fetches before permitting another
unknown-
kidrefresh, serializes concurrent refresh decisions per client, andallows 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:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:LReferences
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 onlyValueErroris affectedDescription
PyJWS._looad()(jwt/api_jws.py:337) splits the compact token, base64url decodes the header segment and hands it tojson.loads()before_verify_signature()runs. The parse is wrapped inexcept ValueError as e: raise DecodeError(...). That is what makes the documented contract sufficient for untrusted input:except jwt.PyJWTError(orInvalidTokenError) aroundjwt.decode(token, key, algorithms=[...]).CPython's JSON decoder raises
RecursionError(aRuntimeErrorsubclass, not aValueError) once nesting crosses the native stack guard. That exception leaves_load(), thenjwt.decode(), and matches noPyJWTErrorhandlerr 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 anAuthorizationheader (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:
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 arraytraceback 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
RecursionErroralongsideValueErrorin_load()and raiseDecodeError, 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:
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'srun_log.txtdocuments a complete proof run.Credits
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 raisesRecursionError, which is not aPyJWTError. The same input is accepted by the publicjwt.decode()path and can escape an application's normalexcept PyJWTErrorhandling.The fix is committed as
06573692ebcdec8831c3927513b3e87c31fbbb62.PyJWS._load()now translates bothValueErrorandRecursionErrorfrom header JSON parsing intoDecodeError. 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:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:LReferences
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/byteskey path, but accepts the same zero-length key when it is supplied as a symmetricPyJWK.An
octJWK containing an empty Base64URL key value:{"kty":"oct","k":""}is decoded to
b"". During signature verification, thePyJWKpath uses this decoded value directly and does not invoke the empty-key validation inHMACAlgorithm.prepare_key. With the defaultenforce_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 emptyoctJWK throughPyJWKorPyJWKSetcan therefore accept attacker-generated tokens as authenticated.The application must already contain an empty HMAC JWK, for example because a missing Base64URL configuration or secret-manager value was serialized as
"k": "". The attacker does not need control of the JWK/JWKS source, a signing oracle, a private key, or a mixed algorithm allow-list.Details
The vulnerability is caused by inconsistent validation of the same HMAC key material depending on whether it enters PyJWT as a raw key or through
PyJWK.Raw empty HMAC keys are rejected
[HMACAlgorithm.prepare_key](https://redirect.github.com/jpadilla/pyjwt/blob/7144e4534c34810f4525dc4578a32addd8212cff/jwt/algorithms.py#L325-L357)rejects an empty HMAC key.As a result, an empty raw key is rejected in PyJWT 2.13.0:
with
InvalidKeyError.Empty
octJWKs decode to the same key but are accepted[HMACAlgorithm.from_jwk](https://redirect.github.com/jpadilla/pyjwt/blob/7144e4534c34810f4525dc4578a32addd8212cff/jwt/algorithms.py#L379-L394)handles symmetric JWKs.For:
{ "kty": "oct", "k": "" }the empty Base64URL value is decoded to:
b""and returned without an emptiness check.
[PyJWK.__init__](https://redirect.github.com/jpadilla/pyjwt/blob/7144e4534c34810f4525dc4578a32addd8212cff/jwt/api_jwk.py#L73-L82)then stores the decoded value inself.key.The resulting
PyJWKtherefore contains the same zero-length byte string rejected by the raw-key path.PyJWKverification bypasses the raw-key validation[PyJWS._verify_signature](https://redirect.github.com/jpadilla/pyjwt/blob/7144e4534c34810f4525dc4578a32addd8212cff/jwt/api_jws.py#L389-L416)has a separate path forPyJWKobjects.When a
PyJWKis supplied, verification uses its already-decoded key directly:The value is not passed through:
so the empty-key check in
HMACAlgorithm.prepare_keyis 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:
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
octsymmetric JWK, HS256 only, and thePyJWKverification 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:
which was the tip of the public
masterbranch 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:
Save the following as
poc.py:Run:
Observed output:
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 withInvalidKeyError.The second control enables
enforce_minimum_key_length, which also rejects the emptyPyJWK.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:
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 replacedHMACAlgorithm.prepare_keywith a function that immediately raises. Verification using thePyJWKstill accepted the forged token, while the raw-key path reachedprepare_keyand 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 aPyJWK, where the configuredoctJWK 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, orsubdoes 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
octJWK supplied throughPyJWKreached HS256 verification asb"", while the equivalent raw empty key was rejected byHMACAlgorithm.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'sprepare_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 throughPyJWK; 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:Nand 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:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:NReferences
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
PyJWKClientfollowed 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
PyJWKClientwith caller-supplied request headers andan 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
kidalone.Affected versions
PyJWT
<= 2.13.0.Fix
The issue is fixed on
masterin commit0a795b8e1f6ef08f634aa7086fc41cc6d5ce3e56.PyJWKClientnow disables automatic redirects for JWKS fetches. Regressiontests 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:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:NReferences
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):
jwt.decodeallow-list mixes an HMAC algorithm with an asymmetric one, e.g.algorithms=["ES256", "HS256"](the RFC 8725 footgun the guard exists to backstop).PyJWKpath, in a byte-form thatcryptography's loader accepts but PyJWT'sis_pem_formatregex 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
cryptographymust 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()returnFalsewhilecryptography.load_pem_public_key()accepts the identical bytes. The asymmetric-key rejection inHMACAlgorithm.prepare_keyis skipped, the public key becomes the HMAC secret, and an attacker who knows the public key mints a validHS256token — universal forgery — whenever the verify allow-list mixes an HMAC and an asymmetric algorithm.Details
At
jwt/algorithms.py:331-335,HMACAlgorithm.prepare_keycontains the sole family-mismatch guard:If neither predicate fires,
:357returnskey_bytesunchanged — the PEM text is used directly as the HMAC secret.is_pem_format(jwt/utils.py:116-127) isbool(_PEM_RE.search(key)), where_PEM_RErequires----[- ]BEGIN (...)[- ]----\r?\n, then.+?\r?\n, then the END marker. The LF in each\r?\nis 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\rterminators, or folded onto one line is not recognized as PEM.cryptography'sload_pem_public_keyis tolerant of exactly these forms and still returns the key.Reach:
jwt/api_jws.py:386performs the allow-list check (passes whenHS256is in the list) and takes the non-PyJWKbranch toalg_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 onis_pem_format) ->is_pem_formatreturnsFalsefor a loader-accepted PEM ->jwt/algorithms.py:357returns the public-key bytes as the HMAC secret ->HS256verification succeeds.Reproduced on PyJWT 2.13.0 (commit
7144e453) withcryptography49.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, confirmis_pem_formatnow returnsFalsewhilecryptographystill loads the bytes, then verify a token signedalg=HS256with the public-key text as the HMAC secret, underalgorithms=["ES256","HS256"](resp.["RS256","HS256"]).Observed output:
Each mutated form on both key types forged a token accepted as
superadmin. Controls: the unmodified PEM is correctly rejected withInvalidKeyError(the guard works and the mutation is load-bearing); a single-algorithm allow-list["ES256"]rejects the forgedHS256token withInvalidAlgorithmError(the mixed allow-list is a necessary precondition).Steps to reproduce:
-----END, convert terminators to bare\r, or join all lines into one.jwt.utils.is_pem_format(mutated) is Falseandcryptography.hazmat.primitives.serialization.load_pem_public_key(mutated)succeeds.jwt.encode({"sub":"superadmin"}, mutated, algorithm="HS256"), thenjwt.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
PyJWKverification 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:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:NReferences
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 doesnot 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 bothrepresentations.
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
The loopback-only lab performs:
/protectedsuccessfully.SHA256(raw_token).!!!!to only the signature segment.a different raw hash, and receives HTTP 200.
jti-indexed revocation and confirm both forms return HTTP 401.Observed statuses for raw-token revocation were
200 -> logout -> 401for thecanonical token and
200for the alternate serialization. Thejticontrolreturned
401for 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
[A-Za-z0-9_-].during verification.
jti, notthe raw serialized token, for revocation and replay state.
Maintainer update — 2026-09-09
We reproduced the reported behavior on the verified
mastercommit8915570: 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 tags2.4.0,2.8.0,2.10.1,2.12.1, and2.13.0.The security impact is co