Repository navigation
Bump PyJWT to 2.15.0 [SECURITY] - #528
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
renovate
Bot
force-pushed
the
renovate/pypi-pyjwt-vulnerability
branch
2 times, most recently
from
March 30, 2026 17:35
4c04285 to
5685fa7
Compare
renovate
Bot
force-pushed
the
renovate/pypi-pyjwt-vulnerability
branch
2 times, most recently
from
April 27, 2026 21:49
5685fa7 to
aadcb05
Compare
renovate
Bot
force-pushed
the
renovate/pypi-pyjwt-vulnerability
branch
2 times, most recently
from
May 20, 2026 05:01
aadcb05 to
d8c99e2
Compare
renovate
Bot
force-pushed
the
renovate/pypi-pyjwt-vulnerability
branch
from
June 16, 2026 19:38
d8c99e2 to
dd1eab1
Compare
renovate
Bot
force-pushed
the
renovate/pypi-pyjwt-vulnerability
branch
from
September 9, 2026 21:24
dd1eab1 to
987daec
Compare
renovate
Bot
force-pushed
the
renovate/pypi-pyjwt-vulnerability
branch
3 times, most recently
from
October 3, 2026 17:39
d29fabf to
f4632b3
Compare
renovate
Bot
force-pushed
the
renovate/pypi-pyjwt-vulnerability
branch
from
October 7, 2026 01:01
f4632b3 to
a35b1cb
Compare
This branch has not been deployed
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.
This PR contains the following updates:
==2.8.0→==2.15.0PyJWT accepts unknown
critheader extensionsCVE-2026-32597 / GHSA-752w-5fwx-jx9f
More information
Details
Summary
PyJWT does not validate the
crit(Critical) Header Parameter defined inRFC 7515 §4.1.11. When a JWS token contains a
critarray listingextensions that PyJWT does not understand, the library accepts the token
instead of rejecting it. This violates the MUST requirement in the RFC.
This is the same class of vulnerability as CVE-2025-59420 (Authlib),
which received CVSS 7.5 (HIGH).
RFC Requirement
RFC 7515 §4.1.11:
Proof of Concept
Expected:
jwt.exceptions.InvalidTokenError: Unsupported critical extension: x-custom-policyActual: Token accepted, payload returned.
Comparison with RFC-compliant library
Impact
gateway using jwcrypto rejects, backend using PyJWT accepts)
critcarries enforcement semantics(MFA, token binding, scope restrictions)
cnf(Proof-of-Possession) can besilently ignored
Suggested Fix
In
jwt/api_jwt.py, add validation in_validate_headers()ordecode():CWE
References
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:NReferences
This data is provided by the GitHub Advisory Database (CC-BY 4.0).
PyJWT: Unauthenticated DoS via unbounded Base64URL decoding of unused payload segment in b64=false detached JWS
CVE-2026-48525 / GHSA-w7vc-732c-9m39
More information
Details
When verifying detached JWS tokens using the unencoded-payload option (
"b64": false, RFC 7797), PyJWT performs Base64URL decoding of the compact-serialization payload segment before enforcing the detached-payload rules.For
b64=false, PyJWT later discards that decoded payload and replaces it with the caller-provideddetached_payload. In practice, this turns the middle segment into an attacker-controlled “work amplifier”: a remote client can supply an arbitrarily large Base64URL payload segment that forces CPU work + memory allocations even if the signature is invalid.This creates an unauthenticated DoS vector against any endpoint that verifies detached JWS using PyJWT.
Affected Component(s)
jwt/api_jws.pyPyJWS.decode()/PyJWS.decode_complete()_load()(parsing and Base64URL decoding)Root Cause (exact logic flaw)
What happens in the code
In
jwt/api_jws.py,decode_complete()does the following (order matters):_load(jwt)first, which decodes the token segmentsheader.get("b64")and ifFalse, it replacespayload = detached_payloadand rebuilds the signing inputThis behavior is visible in
decode_complete():_load(jwt)happens before theb64=falsehandlingpayload = detached_payloadandsigning_input = ... detached_payloadhappens afterward ([GitHub][1])Inside
_load(), PyJWT unconditionally performs:payload = base64url_decode(payload_segment)This is the expensive step the attacker can amplify ([GitHub][1])
Why this becomes a vulnerability
For
b64=falsedetached JWS, the payload segment in compact form is effectively not needed for verification in PyJWT’s own logic (since the library usesdetached_payloadas the real payload). Yet PyJWT still decodes it first, meaning:Impact (evidence-driven)
Security impact
Standards context (RFC 7797)
RFC 7797 explicitly notes this option is used when payload is large and/or detached, and discusses interoperability requirements around marking it critical (“crit” with “b64”). ([IETF Datatracker][2])
(PyJWT supports
critvalidation, but the issue here is decode order / unbounded decode of an unused segment.)Affected Versions
(For GHSA, this phrasing is strong: “confirmed” + “likely since feature introduction”.)
Threat Model
Typical real deployment
A service verifies signed HTTP requests or webhooks using detached JWS:
detached_payloadAttacker
Attack chain
"b64": falseandcrit:["b64"].PyJWS.decode(...detached_payload=...).Proof of Concept - file names + results
PoC placement
server_localhost.py
client_localhost.py
flood_localhost.py
PoC # 1 - Localhost verification server
File: server_localhost.py
Purpose: real HTTP endpoint (
POST /verify) that calls PyJWT detached verification and prints:ok / time_ms / peak_bytes / token_len / error.Results (server console output)
Key takeaways from these results
At 8,000,000 chars, a single invalid-signature request still causes:
PoC # 2 - Localhost network client
File: client_localhost.py
Purpose: generates baseline + (invalid signature) + (valid signature) tokens and sends them over HTTP to localhost server.
Results (client output)
payload-chars = 500,000
payload-chars = 2,000,000
payload-chars = 8,000,000
Why this is strong evidence
PoC # 3 - Localhost flood / burst concurrency
File: flood_localhost.py
Purpose: sends N concurrent invalid-signature requests over HTTP to demonstrate queueing/worker starvation.
Results (your run: 20 concurrent @ 8,000,000 chars)
Interpretation
Fix
Goal
Prevent unbounded resource consumption from an attacker-controlled payload segment that is unused in
b64=falsedetached flow.Minimal change strategy
In
_load()(or by refactoring parse order), do not Base64-decodepayload_segmentuntil after you know whetherb64=falseapplies.Two safe options:
Reject non-empty payload segment when
b64=falseb64is false andpayload_segmentis non-empty → raiseDecodeErrorbefore decodingdetached_payloadonlySkip decoding payload segment entirely when
b64=falseThis aligns with the idea that detached payload is the trusted payload input for verification; the compact payload segment should not become a resource amplification vector.
(Implementation context: the current decode order and unconditional
base64url_decode(payload_segment)are visible in the file and line region around_load()anddecode_complete()([GitHub][1]).)Workarounds
b64=false) is not needed in your app, reject tokens where header includes"b64": false.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 the GitHub Advisory Database (CC-BY 4.0).
PyJWT: Public-key JWK accepted as HMAC secret enables forged HS256 tokens when mixed families are allowed
CVE-2026-48526 / GHSA-xgmm-8j9v-c9wx
More information
Details
Summary
When the verifier is decoding JSON Web Tokens, while supporting both asymmetric and HMAC algorithms, the library does not validate use of JSON Web Keys in HMAC algorithm, allowing attacker to use the issuer public key as the secret key for HMAC algorithm.
Details
In JWT algorithm confusion attack, the verifier is mistakenly use of public key to be used as the shared secret in symmetric algorithms.
In pyjwt case, when the verifier is supporting both HMAC with other asymmetric algorithm and mistakenly using the public key of the issuer to verify the token as demonstrated in the following example:
jws.decode(token, key=rsa_jwk_json, algorithms=["HS256","RS256"]))An attacker who specifies in the token header to use HMAC, will cause the verifier to accept the JWK as the secret key in HMAC algorithm.
The attacker will be able to forge JWT signed with the public key of the issuer to impersonate any user.
If we look on current protections implemented in the library, at class HMACAlgorithm:
We can observe that there is a protection against this type of attacks but only when the verifier is using PEM format or SSH key to verify the token. JSON Web Keys, on the other hand will pass the validation.
In The following example:
jws.decode(token, key=rsa_jwk_json, algorithms=["HS256","RS256"]))There is indeed a wrong implementation of the verifier, but a stronger protection in the library side will prevent and protect against those type of misconfiugrations.
The bypass happens only if the verifier:
(a) allows HS* and an asymmetric algorithm in the same call and (b) passes a public-key value as key.
PoC
Please run the code and observe the payload printed in clear text({"sub":"alice","admin":true}')
Impact
Unauthenticated token forgery → full identity/role impersonation at the resource server (authorization bypass).
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 the GitHub Advisory Database (CC-BY 4.0).
PyJWKClient: missing scheme allowlist enables CVE-2024-21643-class SSRF + token forgery via file://, ftp://, data: schemes
CVE-2026-48522 / GHSA-993g-76c3-p5m4
More information
Details
Summary
PyJWKClient passes its
uriargument directly tourllib.request.urlopen()which uses Python stdlib's defaultOpenerDirectorregisteringHTTPHandler,HTTPSHandler,FTPHandler,FileHandler, andDataHandler. There is currently no documented option to restrict which schemes PyJWKClient will fetch.If an application's
jkuURL ingestion path accepts attacker-influenced URLs (e.g., from JWT header, configuration file, OAuth flow parameter), the attacker can:file://(SSRF on local filesystem) — the file's contents are passed tojson.load.jwt.decode()accepts.Affected versions
Tested and reproducible on PyJWT 2.11.0 and 2.12.1. Likely all versions back to PyJWKClient introduction.
Reproducer (full attack chain — verified empirically)
Cross-library evidence — PyJWT is the outlier
The same composition pattern is structurally safe in 4 other mainstream JWT libraries:
jku=file://...fetch()rejects non-http(s) at fetch-spec layerhttp.DefaultTransportonly registers http/httpsHttpDocumentRetrieverdefaultsRequireHttps=truePyJWT is the only library of these 5 where the default behavior allows
file://to reach the fetch layer.Recommended fix
Add
allowed_schemes: tuple[str, ...] = ("https", "http")kwarg toPyJWKClient.__init__. Pre-validate URL scheme before invokingurllib.request.urlopen. URLs with disallowed schemes raisePyJWKClientErrorbefore any fetch is attempted.Diff sketch against
jwt/jwks_client.pyTests to add
Compatibility
allowed_schemes=("https", "http")preserves backwards compatibility for the overwhelming majority of callers using HTTP/HTTPS JWKS endpointsClass precedent
This is the same class as CVE-2024-21643 (Apache Jena JKU-trust: attacker-supplied JKU URL fetched without scheme validation). NVD-rated CVSS 7.5.
Prior art (verified 2026-05-06)
Confirmed via live recon (NVD direct, OSV.dev, PyJWT GitHub Security Advisories, issue/PR keyword search, CHANGELOG inspection):
Credit
Reported by Keijo Tuominen — independent security research at CMHT.tech (https://cmht.tech).
Reproduction artifacts available on request: full multi-language probe pack (5 wrappers × 25 fixtures × 125 cells) demonstrating cross-library divergence at the URL-scheme boundary.
Severity
CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:L/I:L/A:NReferences
This data is provided by the GitHub Advisory Database (CC-BY 4.0).
PyJWKClient unbounded JWKS endpoint requests via attacker-controlled kid values (DoS)
CVE-2026-48524 / GHSA-fhv5-28vv-h8m8
More information
Details
Summary
PyJWKClient.get_signing_key() forces a fresh HTTP request to the JWKS endpoint for every JWT with an unknown kid value, with no rate limiting. Since kid comes from the unverified token header, an attacker can trigger unlimited outbound requests.
Additionally, fetch_data() finally block clears the JWKS cache on network error.
Root Cause
jwt/jwks_client.py:172-198 - get_signing_key(kid) calls get_signing_keys(refresh=True) for unknown kids, bypassing TTL cache with no cooldown.
jwt/jwks_client.py:120-122 - finally block writes None to cache on error, clearing valid data.
Impact
Suggested Fix
Affected Versions
All versions with PyJWKClient (2.4.0 through 2.12.1)
Severity
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:LReferences
This data is provided by the GitHub Advisory Database (CC-BY 4.0).
PyJWT: Public keys in DER form are accepted as HMAC secrets, bypassing the CVE-2022-29217 guard
CVE-2026-102271 / GHSA-p4g4-x82p-q773
More information
Details
Summary
HMACAlgorithm.prepare_keyblocks asymmetric keys from being used as HMAC secrets by searching for text markers only. It looks for-----BEGINand for anssh-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: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, checksstartswithagainst a list ofssh-andecdsa-sha2-prefixes.A DER encoded public key starts with the bytes
0x30 0x82. It matches neither, soprepare_keyreturns 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:
History of this guard:
Applications that use
PyJWKorPyJWKClientare not affected.jwt/api_jws.py:395binds the headeralgto the key's own algorithm, so HS256 never reachesHMACAlgorithm.prepare_keyon 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_keyandload_der_x509_certificatein 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.No special configuration is needed. The script builds its own key.
Output:
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/keysfixtures, 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.decodeas 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
PyJWKandPyJWKClientare not affected.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 the GitHub Advisory Database (CC-BY 4.0).
PyJWT: PyJWKClient still amplifies unauthenticated JWKS fetches on unknown kid values (incomplete fix of CVE-2026-48524)
CVE-2026-101917 / GHSA-2gx3-rcp4-g85q
More information
Details
Summary
CVE-2026-48524 (GHSA-fhv5-28vv-h8m8, "PyJWKClient unbounded JWKS endpoint requests via attacker-controlled kid values (DoS)") was fixed in 2.13.0 by stopping fetch_data() from clearing the cache on a fetch error. That closed one amplification path but did not add the mitigation the advisory's title implies: there is still no rate-limit, negative-cache, or minimum-refresh-interval for unknown kids.
At HEAD, get_signing_key(kid) (jwt/jwks_client.py:185-211), on any unknown kid, calls get_signing_keys(refresh=True), and refresh=True bypasses jwk_set_cache unconditionally and forces a fresh fetch_data(). The kid is read from the unverified token header (get_signing_key_from_jwt decodes with verify_signature=False), so no valid token and no authentication is required. lru_cache does not cache the raised exception, so even the same unknown kid repeated re-fetches on every call.
Affected
pyjwt <= 2.13.0 (the latest release; the patched release for CVE-2026-48524). No fixed version yet.
Proof of concept (verified on 2.13.0, cache enabled = realistic prod config)
import threading, http.server, socketserver, json
from jwt import PyJWKClient
hits = {'n': 0}
JWKS = json.dumps({"keys":[{"kty":"oct","kid":"real","k":"AAAA"}]}).encode()
class H(http.server.BaseHTTPRequestHandler):
def do_GET(self):
hits['n'] += 1
self.send_response(200); self.send_header('Content-Type','application/json'); self.end_headers()
self.wfile.write(JWKS)
def log_message(self,*a): pass
srv = socketserver.TCPServer(('127.0.0.1',0), H); port = srv.server_address[1]
threading.Thread(target=srv.serve_forever, daemon=True).start()
c = PyJWKClient(f'http://127.0.0.1/:{port}[/jwks](tg://bot_command?command=jwks).json', cache_keys=True, lifespan=3600)
for i in range(8):
try: c.get_signing_key(f'attacker-unknown-kid-{i}')
except Exception: pass
before = hits['n']
for _ in range(5):
try: c.get_signing_key('same-unknown')
except Exception: pass
print('distinct unknown kids: 8 -> fetches:', hits['n'])
print('same unknown kid x5 -> extra fetches:', hits['n'] - before)
Output:
distinct unknown kids: 8 -> fetches: 9
same unknown kid x5 -> extra fetches: 5
Each unknown kid forces a fresh JWKS fetch; a repeated identical unknown kid still re-fetches every time against an unexpired cache. No rate-limit or negative-cache.
Impact
One unauthenticated request -> one outbound JWKS HTTP fetch + full JSON parse on the victim server. An attacker floods tokens carrying junk kids, so the victim hammers its own JWKS/IdP endpoint (amplification: attacker -> victim -> IdP), exhausting victim CPU/sockets and potentially tripping the JWKS provider's rate-limit, causing an application-wide auth outage. This is the unauthenticated DoS the parent advisory is named for, still reachable after the 2.13.0 fix.
Suggested fix
Guard the forced refresh on unknown kids: negative-cache unknown kids for a short TTL, or enforce a minimum interval between forced JWKS refreshes, so a repeated or unknown kid cannot force unbounded fetches.
Note: the same-kid-repeated result (5 identical unknown kids producing 5 fetches against an unexpired cache) shows this is request amplification, not legitimate key-rotation handling, since that refresh can never succeed.
Reported by Babakizo (Securva).
Maintainer update — 2026-09-10
The maintainer confirmed the reported behavior against PyJWT 2.13.0. With JWKS caching
enabled, an unknown
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 the GitHub Advisory Database (CC-BY 4.0).
PyJWT: Asymmetric-PEM detection bypass: whitespace/line-ending-mutated public keys skip the HS/asymmetric confusion guard
CVE-2026-102268 / GHSA-ffc3-869f-jxw9
More information
Details
Prerequisites (both conditions must hold; both are deployment properties, not attacker-controlled at request time):
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 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
More information
Details
Summary