Details
## Package
pyjwt (PyPI)
## Affected versions
tested & verified on: 2.13.0. Every version whose `PyJWS._load()` translates only `ValueError` is affected
## Description
`PyJWS._looad()` (`jwt/api_jws.py:337`) splits the compact token, base64url decodes the header segment and hands it to `json.loads()` before `_verify_signature()` runs. The parse is wrapped in `except ValueError as e: raise DecodeError(...)`. That is what makes the documented contract sufficient for untrusted input: `except jwt.PyJWTError` (or `InvalidTokenError`) around `jwt.decode(token, key, algorithms=[...])`.
CPython's JSON decoder raises `RecursionError` (a `RuntimeError` subclass, not a `ValueError`) once nesting crosses the native stack guard. That exception leaves `_load()`, then `jwt.decode()`, and matches no `PyJWTError` handlerr at all. No key and no valid signature are needed: the header is parsed first, so a single unauthenticated request with an unsigned token suffices.
Scope: the recursion threshold is ~65,000 nesting levels; below it the same input yields a clean `DecodeError` (a 346,800-byte flat header is rejected normally, so depth, not size, is the trigger). A token this large cannot ride in an `Authorization` header (rejected at HTTP field limits,431 under gunicorn), but body transport (RFC 7662 introspection style) is a standard pattern. Under Flask 3.1.3 / gunicorn 26.2.0 each crafted request returns HTTP 500 instead of 401 and writes a full traceback to the error log; the sync worker survives.
## Proof of concept
Against any endpoint that validates a token from its JSON body with the documented pattern:
```python
import base64, json, http.client
def b64url(b): return base64.urlsafe_b64encode(b).rstrip(b"=")
# unsigned token: the header segment is 200,000 nested '[' (266,681 bytes total)
token = (b64url(b"[" * 200_000) + b"." + b64url(b'{"a":1}') + b"." + b64url(b"x")).decode()
conn = http.client.HTTPConnection("127.0.0.1", 8125, timeout=30)
conn.request("POST", "/api/protected", body=json.dumps({"token": token}),
headers={"Content-Type": "application/json"})
print(conn.getresponse().status) # 500, expected 401
```
Determinism: 20/20 crafted requests returned 500 against the reference container (PyJWT 2.13.0, Flask 3.1.3, gunicorn 26.2.0, Python 3.14.7) in 0.12 s (about 6 ms per request in a tight loop, 14 ms for a cold request), each leaving a `RecursionError: Stack overflow ... while decoding a JSON array` traceback in the error log.
## Impact
An unauthenticated attacker can turn every request to a JWT validating endpoint into a server error with a full traceback logged per request, a cheap and repeatable denial of service on the authentication path (no crash, no data exposure).
## Fix
Catch `RecursionError` alongside `ValueError` in `_load()` and raise `DecodeError`, or parse the header with a nonrecursive, depthbounded parser.
## Reproduction
A standalone package (docker-compose.yml, Dockerfile, requirements.txt, target app, driver) accompanies this report:
```
cd <package dir>
docker compose up -d --build
python3 poc_check_then_attack.py
docker compose down -v
```
[poc_pyjwt_recursion.zip](https://github.com/user-attachments/files/31614987/poc_pyjwt_recursion.zip)
Exit 0= reproduced (control checks green, exactly one crafted request returns 500, traceback verified in `docker logs`, service alive afterwards); 1 = not reproduced; 2 = checks failed, no attack sent. The package's `run_log.txt` documents a complete proof run.
## Credits
- @Nivid42
## Maintainer update — 2026-09-09
We reproduced the reported behavior on PyJWT 2.13.0: a deeply nested JSON array in the untrusted compact-JWS header reaches `json.loads()` before signature verification and raises `RecursionError`, which is not a `PyJWTError`. The same input is accepted by the public `jwt.decode()` path and can escape an application's normal `except PyJWTError` handling.
The fix is committed as `06573692ebcdec8831c3927513b3e87c31fbbb62`. `PyJWS._load()` now translates both `ValueError` and `RecursionError` from header JSON parsing into `DecodeError`. A regression test covers the deeply nested-header path before signature verification. The full suite passes with 373 tests and 4 intentional cryptography-environment skips.
No release containing the fix has been published yet, so the patched version remains unset pending release planning. The advisory remains medium severity and unpublished.
## 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.
EPSS — exploit probability
Low0.29%
estimated chance of real-world exploitation in the next 30 days — higher than 19.5% of every CVE FIRST.org scores
Refreshed 9/30/2026 — via FIRST.org's EPSS model, not CVSS — this measures likelihood of exploitation, not how severe it would be.