Prerequisites (both conditions must hold; both are deployment properties, not attacker-controlled at request time):
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).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.
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.
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.
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:
-----END, convert terminators to bare \r, or join all lines into one.jwt.utils.is_pem_format(mutated) is False and cryptography.hazmat.primitives.serialization.load_pem_public_key(mutated) succeeds.jwt.encode({"sub":"superadmin"}, mutated, algorithm="HS256"), then jwt.decode(token, mutated, algorithms=["ES256","HS256"]) — verification succeeds.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.
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.
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.
{
"cwe_ids": [
"CWE-347"
],
"github_reviewed": true,
"github_reviewed_at": "2026-09-29T23:17:33Z",
"nvd_published_at": "2026-09-28T21:17:14Z",
"severity": "CRITICAL"
}