GHSA-8wjv-2p76-3863

Suggest an improvement
Source
https://github.com/advisories/GHSA-8wjv-2p76-3863
Import Source
https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/09/GHSA-8wjv-2p76-3863/GHSA-8wjv-2p76-3863.json
JSON Data
https://api.test.osv.dev/v1/vulns/GHSA-8wjv-2p76-3863
Aliases
Published
2026-09-29T23:43:06Z
Modified
2026-09-29T23:45:52Z
Severity
  • 5.3 (Medium) CVSS_V3 - CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L CVSS Calculator
Summary
PyJWT: Uncaught RecursionError in jwt.decode() on deeply nested token header
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:

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

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.

Database specific
{
    "cwe_ids": [
        "CWE-674"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-09-29T23:43:06Z",
    "nvd_published_at": "2026-09-28T21:17:14Z",
    "severity": "MODERATE"
}
References

Affected packages

PyPI / pyjwt

Package

Affected ranges

Type
ECOSYSTEM
Events
Introduced
2.13.0
Fixed
2.14.0

Affected versions

2.*
2.13.0

Database specific

source
"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/09/GHSA-8wjv-2p76-3863/GHSA-8wjv-2p76-3863.json"