---
id: 2026-09-pyjwt-hmac-key-confusion-token-forgery-cve-batch
title: "PyJWT (the JWT library behind most FastAPI/Flask/Django auth; 152M downloads a week): ten vendor advisories across 2.14.0 (2026-09-11) and 2.15.0 (09-23), eight with CVEs by 09-28 — the asymmetric-key guard that stops a public key being used as an HMAC secret missed PEMs with unusual whitespace or line endings (CVE-2026-102268, 9.1), DER-encoded public keys (CVE-2026-102271, 7.4) and empty keys supplied as a JWK (CVE-2026-102266, 7.4), so an application that lists both HS256 and an asymmetric algorithm accepts tokens anyone with the public key can forge; PyJWKClient followed redirects when fetching key sets (CVE-2026-102267, 7.4); plus a claims-verification regression on reused options dicts, OKP key-identity confusion and two parser DoS bugs"
date_disclosed: 2026-09-11
last_updated: 2026-10-06
severity: critical
status: patched
ecosystems: [pypi, python, auth, fastapi]
tools_affected: ["PyJWT ≤ 2.13.0 (2.14.0 fixes CVE-2026-102268, -102271, -102266, -102267, -102269)", "PyJWT ≤ 2.14.0 (2.15.0 fixes CVE-2026-102275, CVE-2026-101918)", "PyJWT 2.11.0–2.13.0 (CVE-2026-103001, options-dict mutation; no patched version listed on the advisory)", "any FastAPI, Flask, Django or Starlette app that calls jwt.decode() with more than one algorithm in `algorithms=[…]`, or that fetches a JWKS with PyJWKClient"]
tags: [cve-batch, auth-bypass, token-forgery, algorithm-confusion, key-confusion, jwt, jwks, redirect, dos, fastapi, pypi, vendor-ghsa, database-backfill]
---

## TL;DR

**PyJWT** is the `import jwt` in essentially every Python auth tutorial — the FastAPI security docs, Flask extensions, Django REST add-ons — and pulled **152,382,603 downloads in the last week** (pypistats, 2026-10-06). Between **2026-09-08 and 09-23** its maintainer published **ten advisories**; **2.14.0 shipped 2026-09-11** and **2.15.0** followed. GitHub (CNA) issued eight CVEs by 09-28, and the database's reviewed `pip` critical listing surfaced the 9.1 on 09-29 with two more on 10-05. The headline is an old bug class returning:

| Bug | Id | Score | Affected → fixed |
|---|---|---|---|
| **Asymmetric-PEM detection bypass → HS256 forgery.** `is_pem_format()` did not recognise PEMs with marker-adjacent whitespace, CR-only line endings or single-line folding that `cryptography`'s loader still accepts; under a mixed allow-list such as `["ES256", "HS256"]` the public key passed the guard and became the HMAC secret, so "an attacker who knows the public key can forge authenticated HMAC tokens" | [GHSA-ffc3-869f-jxw9](https://github.com/advisories/GHSA-ffc3-869f-jxw9) / **CVE-2026-102268** | **9.1** | `≤ 2.13.0` → **2.14.0** |
| **DER public keys accepted as HMAC secrets**, bypassing the CVE-2022-29217 guard, which looked for `-----BEGIN` and `ssh-` text markers that binary DER (SubjectPublicKeyInfo, PKCS#1, X.509) does not carry | [GHSA-p4g4-x82p-q773](https://github.com/advisories/GHSA-p4g4-x82p-q773) / **CVE-2026-102271** | 7.4 | `≥ 2.4.0, < 2.14.0` → 2.14.0 |
| **Empty HMAC key via PyJWK.** Raw empty keys were rejected; the same zero-length key supplied as `{"k": ""}` was not, so anyone can compute `HMAC-SHA256(b"", …)` | [GHSA-9j54-fg26-wv3r](https://github.com/advisories/GHSA-9j54-fg26-wv3r) / **CVE-2026-102266** | 7.4 | `2.13.0` only → 2.14.0 |
| **PyJWKClient followed redirects** when fetching a JWKS, so a compromised or attacker-influenced endpoint could redirect to key material of its choosing (and leak custom request headers) | [GHSA-9v7f-9g4p-ffgj](https://github.com/advisories/GHSA-9v7f-9g4p-ffgj) / **CVE-2026-102267** | 7.4 | `≤ 2.13.0` → 2.14.0 |
| **Non-canonical signature segments** — characters outside the Base64URL alphabet were accepted, giving an alternate serialisation that verifies identically but hashes differently; defeats revocation lists keyed on the raw token string | [GHSA-hxm8-2xgr-2p9m](https://github.com/jpadilla/pyjwt/security/advisories/GHSA-hxm8-2xgr-2p9m) / **CVE-2026-102269** | 4.8 | `≤ 2.13.0` → 2.14.0 |
| **`decode()` mutates the caller's `options` dict** — a regression of a 2022 fix, reintroduced in 2.11.0; reusing one dict across a `verify_signature=False` call and a later "secure" call silently disables claim checks (exp, aud, iss) | [GHSA-gvp8-978c-rx2q](https://github.com/jpadilla/pyjwt/security/advisories/GHSA-gvp8-978c-rx2q) / **CVE-2026-103001** | 6.5 | `2.11.0–2.13.0`; **advisory lists no patched version** (fetched 2026-10-06) |
| **OKP JWK `x`/`d` identity confusion** — a private JWK whose public `x` does not match the key derived from `d` is accepted; matters for DPoP-style sender binding | [GHSA-x33g-cr3x-6449](https://github.com/advisories/GHSA-x33g-cr3x-6449) / **CVE-2026-102275** | 6.5 | `≥ 2.1.0, ≤ 2.14.0` → **2.15.0** (vendor 09-23; database 10-05) |
| **RecursionError DoS** in `PyJWKClient.get_signing_key_from_jwt` pre-verification payload parse | [GHSA-42vr-xj54-vc7v](https://github.com/jpadilla/pyjwt/security/advisories/GHSA-42vr-xj54-vc7v) / **CVE-2026-101918** | 5.3 | `≥ 2.0.0a1, < 2.15.0` → 2.15.0 |

Two more on the tab — **GHSA-w6j9-cwv2-h6wq** (a malformed RSA JWK aborts parsing of the whole JWK Set) and **GHSA-8wjv-2p76-3863** (uncaught RecursionError on a deeply nested header) — are fixed in 2.14.0 per the changelog and were not opened individually. **Upgrade to 2.15.1** (latest on PyPI, 2026-10-06), and then read the precondition: the three forgery bugs only bite when `algorithms=` **mixes HMAC with an asymmetric algorithm**. If your `decode()` call lists exactly one algorithm, you are exposed to the redirect, DoS and options-dict bugs but not to forgery.

## What happened

**The class.** CVE-2022-29217 was the original PyJWT algorithm-confusion fix: refuse to use something that looks like a public key as an HMAC secret, because under a mixed allow-list an attacker can take the server's *public* key, sign an HS256 token with it as the shared secret, and the server — handed an `alg: HS256` header — verifies with the same bytes. The 2022 guard was a text check for PEM/SSH markers. September's three forgery advisories are three ways to hand the loader a public key the text check does not recognise: a PEM with odd whitespace (CVE-2026-102268, the only one rated `AC:L` and therefore 9.1), binary DER (CVE-2026-102271), and a JWK with an empty `k` (CVE-2026-102266, a different mechanism with the same effect — the key is empty rather than public, and the attacker knows an empty key too). The 2.14.0 changelog describes the fix as hardening "HMAC key validation against public-key material supplied as JWK, JWKS, array, encoded, BOM-prefixed, DER, or PEM input" — i.e. the guard now reasons about key material, not markers.

**The preconditions are specific, and the vendor states them.** CVE-2026-102268 "requires two deployment preconditions: mixed algorithm allow-lists and PEM keys passed as raw text/bytes with formatting mutations." CVE-2026-102271 affects "applications mixing asymmetric and HMAC algorithms while storing public keys as raw DER bytes"; apps using `PyJWK`/`PyJWKClient` are unaffected by that one. CVE-2026-102266 needs an empty symmetric JWK present in the application's key set. The 9.1 is the one to lead with because `["RS256", "HS256"]` is a real pattern in generated code — it appears whenever an agent is asked to "support both" token types — and PEM files with Windows or copy-paste line endings are common.

**The one with no patched version.** GHSA-gvp8-978c-rx2q (CVE-2026-103001) says 2.11.0–2.13.0 affected and lists no fix; the 2.14.0 changelog does not name it. Treat it as open and avoid reusing a mutable `options` dict across calls with different verification intent — pass a fresh dict, or a `MappingProxyType`.

**Timeline.** Advisories 09-08 (two), 09-11 (six, with the 2.14.0 release), 09-23 (two); CVE records 09-28; database reviewed listing 09-29 (the 9.1) and 10-05 (CVE-2026-102275, CVE-2026-101918). None of this reached a news outlet this sweep could find; the vendor tab and the database were the only channels.

## Am I affected?

```bash
pip show PyJWT 2>/dev/null | grep -i ^version            # < 2.15.0 → yes
pip index versions pyjwt 2>/dev/null | head -1           # 2.15.1 is current
# The forgery precondition — more than one algorithm, mixing HS* with RS*/ES*/PS*/EdDSA:
grep -rn "algorithms\s*=\s*\[" --include='*.py' . | grep -i 'HS' | grep -iE 'RS|ES|PS|EdDSA'
# JWKS fetching (redirect bug):
grep -rn "PyJWKClient" --include='*.py' .
# Options-dict reuse (CVE-2026-103001):
grep -rn "verify_signature.*False" --include='*.py' .
```

- FastAPI's documented pattern (`jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])`, one algorithm) is **not** exposed to the forgery bugs. Generated code that lists several algorithms, or that passes a public key file as the `key` for a list containing `HS256`, **is**.
- Identity-provider integrations (Auth0, Cognito, Clerk, Supabase, Keycloak) verified with `PyJWKClient` are exposed to CVE-2026-102267 if the JWKS URL could be redirected — a compromised DNS entry or a provider-side open redirect.

## If you are affected

1. **Upgrade to PyJWT 2.15.1.** It is a drop-in minor; the stricter HMAC-key validation can reject keys that previously "worked," which is the point — if an upgrade breaks token verification, the key you were passing was not the kind you thought.
2. **Fix the allow-list regardless of version:** one algorithm per `decode()` call, chosen by the application, never taken from the token header. If you must accept two token types, verify them in two code paths with two keys.
3. If a mixed allow-list and an asymmetric key were in production together, **assume forged tokens were possible for the whole window** and rotate the signing key pair so that previously forged tokens stop verifying — [rotating-cloud-credentials.md](../playbooks/rotating-cloud-credentials.md); review auth logs for HS256 tokens on an endpoint that should only see RS256/ES256 — [if-your-webapp-was-compromised.md](../playbooks/if-your-webapp-was-compromised.md).
4. Pin the JWKS URL to HTTPS and, where the provider supports it, pin the issuer; the 2.14.0 client no longer follows redirects, but a key set that moved will now fail closed and you want to know why.

## Prevention

- Algorithm confusion is a 2015 bug class that libraries keep re-fixing; the durable control is on the application side — a single algorithm, a key of that algorithm's type, and never a public key where a secret belongs — [prevention/credential-hygiene.md](../prevention/credential-hygiene.md).
- Keep the auth library on the current release and watch its advisory tab; ten advisories in two weeks did not generate a single headline, and `pip audit` only knows the ones with CVEs — [prevention/supply-chain-attack-surface.md](../prevention/supply-chain-attack-surface.md).
- When reviewing generated auth code, grep for `algorithms=[` and `verify_signature` first; those two lines carry most of the risk in this file.

## Why this matters for vibe coders

The JWT verification in a generated FastAPI backend is one function, written once, never revisited. If the prompt said "support both our own HS256 tokens and the provider's RS256 ones," the agent produced the mixed allow-list these advisories need, and the public key it downloaded into `keys/` is the forgery secret. Upgrading PyJWT closes the three recognisers that failed; removing the mixed list closes the class.

## Sources

- [GHSA-ffc3-869f-jxw9 — PyJWT: Asymmetric-PEM detection bypass (CVE-2026-102268)](https://github.com/advisories/GHSA-ffc3-869f-jxw9) — fetched 2026-10-06: 9.1 `AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N`, `≤ 2.13.0` → 2.14.0, the `is_pem_format()` gaps (marker-adjacent whitespace, CR-only terminators, single-line folding), the `["ES256", "HS256"]` example, the two preconditions, reporter e1024x, fix commit 8b4e233a, published 2026-09-11.
- [GHSA-p4g4-x82p-q773 — Public keys in DER form accepted as HMAC secrets, bypassing the CVE-2022-29217 guard (CVE-2026-102271)](https://github.com/advisories/GHSA-p4g4-x82p-q773) — fetched 2026-10-06: 7.4, `≥ 2.4.0, < 2.14.0`, the three DER encodings, `PyJWK`/`PyJWKClient` users unaffected, reporter 0xSmiley, commit 2798504.
- [GHSA-9j54-fg26-wv3r — PyJWK accepts empty HMAC keys (CVE-2026-102266)](https://github.com/advisories/GHSA-9j54-fg26-wv3r) — fetched 2026-10-06: 7.4, `≥ 2.13.0, < 2.14.0`, the `{"k": ""}` forgery, reporter hsnyus-09, commit f91ed44.
- [GHSA-9v7f-9g4p-ffgj — PyJWKClient follows redirects when fetching JWKS (CVE-2026-102267)](https://github.com/advisories/GHSA-9v7f-9g4p-ffgj) — fetched 2026-10-06: 7.4, `≤ 2.13.0` → 2.14.0, header leakage and "JWKS trust poisoning," reporter NovaHunter06, commit 0a795b8.
- [GHSA-hxm8-2xgr-2p9m — Non-canonical signature segments enable raw-token revocation bypass (CVE-2026-102269)](https://github.com/jpadilla/pyjwt/security/advisories/GHSA-hxm8-2xgr-2p9m) — fetched 2026-10-06: 4.8, `≤ 2.13.0` → 2.14.0, CWE-180, the `jti` recommendation, reporter ze3tar.
- [GHSA-gvp8-978c-rx2q — PyJWT.decode() reintroduces options-dict mutation (CVE-2026-103001)](https://github.com/jpadilla/pyjwt/security/advisories/GHSA-gvp8-978c-rx2q) — fetched 2026-10-06: 6.5, `≥ 2.11.0, ≤ 2.13.0`, **no patched version listed**, regression of issue #679 via PR #1045 (merged 2025-03-05), reporter tritsystem, published 2026-09-08.
- [GHSA-x33g-cr3x-6449 — PyJWT accepts inconsistent OKP x/d JWKs (CVE-2026-102275)](https://github.com/advisories/GHSA-x33g-cr3x-6449) — fetched 2026-10-06: 6.5 `AC:H`, `≥ 2.1.0, ≤ 2.14.0` → 2.15.0, the DPoP scenario, CWE-345/348, reporter ze3tar, vendor 2026-09-23, database 2026-10-05, commit 3cd9ceec.
- [NVD API — CVE-2026-102268, CVE-2026-102275, CVE-2026-101918](https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=CVE-2026-102268) — fetched 2026-10-06: all three published 2026-09-28 by `security-advisories@github.com`; 9.1 / 6.5 / 5.3; references to the fix commits and the 2.14.0 / 2.15.0 release tags.
- [jpadilla/pyjwt — security advisories tab](https://github.com/jpadilla/pyjwt/security/advisories) — fetched 2026-10-06: the ten advisories with vendor dates (09-08 ×2, 09-11 ×6, 09-23 ×2).
- [jpadilla/pyjwt — CHANGELOG.rst at 2.14.0](https://github.com/jpadilla/pyjwt/blob/2.14.0/CHANGELOG.rst) — fetched 2026-10-06: the five "Security" bullets with their GHSA ids (including GHSA-r6x4-923q-g947, GHSA-w2cx-738m-mc7w, GHSA-2gx3-rcp4-g85q, GHSA-mvj7-wp6q-v59j and GHSA-pxh4-856f-4h89, which are not on the public tab and were not opened).
- [jpadilla/pyjwt — release 2.14.0](https://github.com/jpadilla/pyjwt/releases/tag/2.14.0) — fetched 2026-10-06: tagged September 11 (commit c6fe464).
- [PyPI — `pip index versions pyjwt`](https://pypi.org/project/PyJWT/) — 2026-10-06: 2.15.1 latest; 2.14.0 and 2.15.0 present. [pypistats — recent downloads](https://pypistats.org/api/packages/pyjwt/recent) — 2026-10-06: 152,382,603 last week.
- [GitHub Advisory Database — reviewed `pip` critical listing](https://github.com/advisories?query=type%3Areviewed+ecosystem%3Apip+severity%3Acritical) and [`agent` recency listing](https://github.com/advisories?query=agent+sort%3Apublished-desc) — fetched 2026-10-06: CVE-2026-102268 listed Sep 29; CVE-2026-102275 listed Oct 5.
- Related in this repo: [better-auth OAuth/OIDC wave](2026-07-better-auth-oauth-oidc-mcp-vulnerabilities.md), [NextAuth magic-link homoglyph bypass](2026-07-nextauth-magic-link-homoglyph-bypass.md), [ZITADEL account-takeover wave](2026-10-zitadel-login-account-takeover-advisory-wave.md).
