PyJWT ships a guard rail specifically for algorithm confusion: before it will let you use a key as an HMAC secret, it checks whether that key looks like an asymmetric key or certificate, and refuses if it does. That check is a single regex. Regexes are exact. PEM parsing, in the real world, is not.

We took the RSA public key from our lab, changed nothing about its content, indented one line by four spaces, and asked PyJWT to verify an HS256 token signed with that file as the secret. It worked.


Root Cause: Two Parsers, One File, Two Opinions

PyJWT’s HMAC key handler exists to stop a well-known class of bug: an application configured to accept both RS256 and HS256 hands an attacker a one-line forgery primitive, because the “public” key used to verify RS256 signatures can also be used as the secret for an HS256 signature — and the attacker already has that public key. PyJWT’s defense against this is HMACAlgorithm.prepare_key(), which runs every candidate HMAC secret through is_pem_format() and is_ssh_key() first. If the key looks like a PEM-encoded asymmetric key or an SSH public key, PyJWT raises InvalidKeyError and stops.

Before 2.14.0, is_pem_format() was one regex:

# jwt/utils.py (PyJWT < 2.14.0)
_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))

Read literally, this regex wants: a BEGIN marker, then \r?\n, then the key body (matched non-greedily), then \r?\n, then an END marker whose label matches the BEGIN label via backreference. The [- ] pieces allow the marker dashes to be separated from the label by either a dash or a single space — a nod to the handful of marker styles that show up in the wild.

What it doesn’t allow for is anything between that trailing newline and the END marker’s leading dashes. Real PEM files, and the cryptography library’s own loader, are considerably less picky: leading whitespace on the marker line, \r-only line endings, or a folded single-line blob are all things cryptography.hazmat.primitives.serialization.load_pem_public_key() will parse without hesitation, because its job is to find a valid base64 payload between two recognizable marker strings, not to reject anything that deviates from a canonical layout.

That gap is the entire vulnerability. GitHub’s advisory describes it plainly:

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.

is_pem_format() says “not a PEM.” cryptography says “valid RSA public key.” PyJWT trusts the regex, not the library actually doing the parsing — so the asymmetric-key guard in HMACAlgorithm.prepare_key() never fires, and the raw key bytes sail through as an ordinary HMAC secret.

The Edge Case, In Four Spaces

Our lab’s public_key.pem is an unmodified 2048-bit RSA public key with one deliberate formatting change: the final marker line is indented.

1wIDAQAB
    -----END PUBLIC KEY-----

Four leading spaces, nothing else. cryptography’s loader parses it without a warning. PyJWT’s _PEM_RE, however, requires the END marker’s dashes to appear immediately after the preceding \r?\n — [- ]END allows exactly one separator character before the label, and that separator has to be a dash or a space adjacent to the marker itself, not four spaces of line indentation sitting in front of it. The non-greedy body match (.+?\r?\n) has nowhere else in the file to find a matching END marker, so the overall regex fails to match, and is_pem_format() returns False on a file that every actual PEM parser accepts.

This is exactly the “marker-adjacent whitespace” variant named in the advisory — alongside CR-only terminators and single-line-folded PEMs, which break the same \r?\n assumptions in different ways.


Lab Breakdown: Minimal App, Maximal Consequence

The lab is deliberately small — this isn’t a chain of bugs, it’s one missed regex match sitting behind a very common configuration choice.

app.py is a Flask service with one route, /verify, that exists purely to check JWTs:

decoded_payload = jwt.decode(
    token,
    key=PUBLIC_KEY,
    algorithms=["RS256", "HS256"]
)

That algorithms=["RS256", "HS256"] line is the precondition the whole attack depends on. It’s not an exotic configuration — allowing a short list of “algorithms we currently support” is a common way to handle key rotation or multi-tenant deployments, and plenty of real codebases accumulate both an asymmetric and a symmetric algorithm in that list over time without anyone treating it as a security decision. PUBLIC_KEY is loaded once at startup, straight from public_key.pem, with no re-validation.

utils.py is the matching proof-of-concept for the attacker’s side of the exchange — it signs a token with PUBLIC_KEY as an HS256 secret and then decodes it the same way:

encoded_jwt = jwt.encode({"some": "payload"}, PUBLIC_KEY, algorithm="HS256")
...
decoded_jwt = jwt.decode(encoded_jwt, PUBLIC_KEY, algorithms=["HS256"])

Nothing in utils.py requires the attacker to have ever touched the server. PUBLIC_KEY is, definitionally, public — it’s handed out to anyone who needs to verify an RS256 token the legitimate issuer signs. An attacker who can read that key (from a .well-known/jwks.json endpoint, a config file, a GitHub repo, anywhere) has everything utils.py has.

# podman-compose.yaml
services:
  jwt-vuln-lab:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: pyjwt-vuln-app
    ports:
      - "8080:8080"
    restart: unless-stopped
# requirements.txt
Flask==2.2.5
Werkzeug==2.2.3
PyJWT==2.4.0

podman-compose up -d builds the vulnerable service from the pinned PyJWT==2.4.0 — any pre-2.14.0 release reproduces the same behavior, since the flawed regex was unchanged across that whole range — and exposes /verify on 127.0.0.1:8080.

Forging a Token Against the Running Service

With the service up, the entire exploit is: run utils.py to mint an HS256 token using the server’s own public key, then POST it.

python utils.py
# -> eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

Encoding JWT with public_key.pem

curl -s http://127.0.0.1:8080/verify \
  -H 'Content-Type: application/json' \
  -d '{"token": "<forged-jwt-here>"}'

The server runs jwt.decode(token, key=PUBLIC_KEY, algorithms=["RS256", "HS256"]). The token’s header says alg: HS256. PyJWT’s prepare_key() for the HMAC path checks PUBLIC_KEY against is_pem_format(), gets False back because of the four-space indent, and happily treats the public key bytes as a legitimate HMAC secret. It computes HMAC-SHA256 over the token with that “secret,” the signature matches — because the attacker computed it the exact same way — and /verify returns {"message": "Access granted", ...} for a token nobody with the actual private key ever signed.

Access granted


Source Code & Diff Analysis: What 2.14.0 Actually Changes

The fix landed in commit 8b4e233, released as part of PyJWT 2.14.0 on 2026-09-11. It replaces the single backtracking regex with a one-pass scanner over the supported BEGIN/END marker set, rather than trying to patch the existing pattern with more lookarounds.

The maintainers’ own description of the change, from the advisory record, is precise about what changed and why:

PyJWT now scans supported PEM BEGIN/END markers in one pass, preserving matching labels and handling overlapping markers without regex backtracking.

Two things are packed into that sentence, and both map directly onto our lab’s reproduction:

It no longer depends on an exact \r?\n adjacency between the key body and the marker line. A scanner that walks the byte string looking for marker tokens — rather than anchoring a regex to a specific newline sequence immediately before the dashes — doesn’t care whether the END marker is indented, prefixed with stray whitespace, or sitting on a line terminated with \r alone instead of \r\n or \n. All three of the advisory’s named bypass variants (marker-adjacent whitespace, CR-only terminators, single-line-folded PEMs) break because the old regex is strict about exactly one thing: the byte sequence immediately surrounding the markers. Removing that strictness closes all three at once, because they’re really one bug, not three.

It fixes a second, related CVE in the same patch. The old _PEM_RE is also the subject of CVE-2026-102270 — a ReDoS in the same function, triggered by certificate-like input with repeated BEGIN markers and no matching END, which makes the regex engine backtrack extensively. That’s not a coincidence; it’s the same root design problem from the opposite direction. A hand-rolled backtracking regex trying to match a BEGIN ... END pair across arbitrary content is simultaneously too strict about formatting (missing valid PEMs, which is CVE-2026-102268) and too expensive on adversarial input (CVE-2026-102270). A linear one-pass scan is the fix for both, because it replaces “a regex that has to get every formatting assumption right” with “code that doesn’t make formatting assumptions at all.”

The practical effect for our lab: on PyJWT 2.14.0, is_pem_format(PUBLIC_KEY) returns True regardless of the four-space indent, the HMACAlgorithm.prepare_key() guard fires as designed, and jwt.decode() raises InvalidKeyError before the forged HS256 token is ever evaluated — closing the gap between what the regex sees and what cryptography was willing to parse all along.

Encoding operation failed in PyJWT 2.14.0


References

ResourceLink
AdvisoryGHSA-ffc3-869f-jxw9
Fix commit8b4e233a22206b34ec1186e912e75c0b2396ac07
Related ReDoS advisory (same fix commit)GHSA-jwrc-g2q2-pq5p / CVE-2026-102270
PyJWT 2.14.0 releasejpadilla/pyjwt releases
Vulnerable repojpadilla/pyjwt