PyJWT: Asymmetric-PEM detection bypass: whitespace/line-ending-mutated public keys skip the HS/asymmetric confusion guard
🔗 CVE IDs covered (1)
📋 Description
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.
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:
- 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.
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.
🎯 Affected products1
- pip/PyJWT:<= 2.13.0
🔗 References (5)
- https://github.com/jpadilla/pyjwt/security/advisories/GHSA-ffc3-869f-jxw9
- https://nvd.nist.gov/vuln/detail/CVE-2026-102268
- https://github.com/jpadilla/pyjwt/commit/8b4e233a22206b34ec1186e912e75c0b2396ac07
- https://github.com/jpadilla/pyjwt/releases/tag/2.14.0
- https://github.com/advisories/GHSA-ffc3-869f-jxw9