Summary
PyJWT: Asymmetric-PEM detection bypass: whitespace/line-ending-mutated public keys skip the HS/asymmetric confusion guard
Prerequisites (both conditions must hold; both are deployment properties, not attacker-controlled at request time):
- The
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). - The verification key is passed as raw PEM text/bytes on the non-
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 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.
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:
- Generate an EC P-256 (or RSA-2048) keypair; serialize the public key to PEM.
- 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. - Confirm
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.
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.
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.
CVE-2026-102268 has a CVSS score of 9.1 (Critical). The vector is network-reachable, no privileges required, and no user interaction. A CVSS score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether this affects your application depends on whether the vulnerable code is present and reachable in your environment. A fixed version is available (2.14.0); upgrading removes the vulnerable code path.
Affected versions
Security releases
Kodem intelligence
Severity tells you how bad this could be in the worst case. It does not tell you whether you are exposed. Exploitability and impact are functions of runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A vulnerable package can sit in your dependency tree and never run.
Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter. Kodem's runtime-powered SCA identifies whether this CVE is reachable in your applications.
Already deployed Kodem?
See it in your environmentNew to Kodem? Get a demo →Remediation advice
Kodem Kai can prioritize this vulnerability in your dependency tree and generate a fix recommendation.
Frequently Asked Questions
- What is CVE-2026-102268? CVE-2026-102268 is a critical-severity security vulnerability in PyJWT (pip), affecting versions <= 2.13.0. It is fixed in 2.14.0.
- How severe is CVE-2026-102268? CVE-2026-102268 has a CVSS score of 9.1 (Critical). This score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether it represents real risk in your environment depends on whether the vulnerable code is present and reachable.
- Which versions of PyJWT are affected by CVE-2026-102268? PyJWT (pip) versions <= 2.13.0 is affected.
- Is there a fix for CVE-2026-102268? Yes. CVE-2026-102268 is fixed in 2.14.0. Upgrade to this version or later.
- Is CVE-2026-102268 exploitable, and should I be worried? Whether CVE-2026-102268 is exploitable in your environment depends on whether the vulnerable code is present and reachable. A CVSS score is a worst-case rating; it does not account for your specific deployment, configuration, or usage patterns. Kodem, an Intelligent Application Security platform, uses runtime intelligence to show which vulnerabilities actually execute in production, so you can focus on the ones that represent real risk. Get a demo
- What actually determines whether CVE-2026-102268 is exploitable, and how bad it is? Exploitability and impact are not fixed properties of a CVE. They depend on runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A high CVSS score on a dependency that never runs is not the same as real risk. Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter.
- How do I fix CVE-2026-102268? Upgrade
PyJWTto 2.14.0 or later.