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.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.
describes (1)
Related CVEs.
| ID | severity | exploitable | ||
|---|---|---|---|---|
| CVE-2026-102268US | — | — | ||
PyJWT is a Python implementation of JSON Web Token standards. Prior to 2.14.0, is_pem_format in jwt/utils.py is affected because is_pem_format does not recognize every PEM represen… | ||||
references
External citations — patches, advisories, analysis.
- https://github.com/advisories/GHSA-ffc3-869f-jxw9ADVISORY
- https://github.com/jpadilla/pyjwtPACKAGE
- https://github.com/jpadilla/pyjwt/commit/8b4e233a22206b34ec1186e912e75c0b2396ac07WEB
- https://github.com/jpadilla/pyjwt/releases/tag/2.14.0WEB
- https://github.com/jpadilla/pyjwt/security/advisories/GHSA-ffc3-869f-jxw9WEB
- https://nvd.nist.gov/vuln/detail/CVE-2026-102268ADVISORY
- https://pypi.org/project/pyjwtPACKAGE
Advisories like this name the exposed products attackers go after on your perimeter. See what is exposed on your perimeter with a free passive scan.
Free scan