Action not permitted
Modal body text goes here.
Modal Title
Modal Body
CVE-2026-102265 (GCVE-0-2026-102265)
Vulnerability from cvelistv5 – Published: 2026-09-28 20:48 – Updated: 2026-09-28 20:48- CWE-674 - Uncontrolled Recursion
| URL | Tags |
|---|---|
| https://github.com/jpadilla/pyjwt/security/adviso… | x_refsource_CONFIRM |
| https://github.com/jpadilla/pyjwt/commit/06573692… | x_refsource_MISC |
| https://github.com/jpadilla/pyjwt/releases/tag/2.14.0 | x_refsource_MISC |
{
"containers": {
"cna": {
"affected": [
{
"product": "pyjwt",
"vendor": "jpadilla",
"versions": [
{
"status": "affected",
"version": "\u003e= 2.13.0, \u003c 2.14.0"
}
]
}
],
"descriptions": [
{
"lang": "en",
"value": "PyJWT is a Python implementation of JSON Web Token standards. From 2.13.0 until 2.14.0, PyJWS._load in jwt/api_jws.py is affected because parser catches ValueError but not RecursionError. This occurs when a deeply nested token header reaches json.loads. As a result, RecursionError escapes the documented PyJWT error hierarchy. Consequently, an unauthenticated malformed token can cause a request-level failure and HTTP 500. This issue is fixed in version 2.14.0."
}
],
"metrics": [
{
"cvssV3_1": {
"attackComplexity": "LOW",
"attackVector": "NETWORK",
"availabilityImpact": "LOW",
"baseScore": 5.3,
"baseSeverity": "MEDIUM",
"confidentialityImpact": "NONE",
"integrityImpact": "NONE",
"privilegesRequired": "NONE",
"scope": "UNCHANGED",
"userInteraction": "NONE",
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L",
"version": "3.1"
}
}
],
"problemTypes": [
{
"descriptions": [
{
"cweId": "CWE-674",
"description": "CWE-674: Uncontrolled Recursion",
"lang": "en",
"type": "CWE"
}
]
}
],
"providerMetadata": {
"dateUpdated": "2026-09-28T20:48:04.664Z",
"orgId": "a0819718-46f1-4df5-94e2-005712e83aaa",
"shortName": "GitHub_M"
},
"references": [
{
"name": "https://github.com/jpadilla/pyjwt/security/advisories/GHSA-8wjv-2p76-3863",
"tags": [
"x_refsource_CONFIRM"
],
"url": "https://github.com/jpadilla/pyjwt/security/advisories/GHSA-8wjv-2p76-3863"
},
{
"name": "https://github.com/jpadilla/pyjwt/commit/06573692ebcdec8831c3927513b3e87c31fbbb62",
"tags": [
"x_refsource_MISC"
],
"url": "https://github.com/jpadilla/pyjwt/commit/06573692ebcdec8831c3927513b3e87c31fbbb62"
},
{
"name": "https://github.com/jpadilla/pyjwt/releases/tag/2.14.0",
"tags": [
"x_refsource_MISC"
],
"url": "https://github.com/jpadilla/pyjwt/releases/tag/2.14.0"
}
],
"source": {
"advisory": "GHSA-8wjv-2p76-3863",
"discovery": "UNKNOWN"
},
"title": "PyJWT: Uncaught RecursionError in jwt.decode() on deeply nested token header"
}
},
"cveMetadata": {
"assignerOrgId": "a0819718-46f1-4df5-94e2-005712e83aaa",
"assignerShortName": "GitHub_M",
"cveId": "CVE-2026-102265",
"datePublished": "2026-09-28T20:48:04.664Z",
"dateReserved": "2026-09-28T20:11:16.658Z",
"dateUpdated": "2026-09-28T20:48:04.664Z",
"state": "PUBLISHED"
},
"dataType": "CVE_RECORD",
"dataVersion": "5.2",
"vulnerability-lookup:meta": {
"epss": {
"cve": "CVE-2026-102265",
"date": "2026-09-30",
"epss": "0.00291",
"percentile": "0.1959"
},
"nvd": {
"cve": {
"affected": [
{
"affectedData": [
{
"product": "pyjwt",
"vendor": "jpadilla",
"versions": [
{
"status": "affected",
"version": "\u003e= 2.13.0, \u003c 2.14.0"
}
]
}
],
"source": "security-advisories@github.com"
}
],
"cveTags": [],
"descriptions": [
{
"lang": "en",
"value": "PyJWT is a Python implementation of JSON Web Token standards. From 2.13.0 until 2.14.0, PyJWS._load in jwt/api_jws.py is affected because parser catches ValueError but not RecursionError. This occurs when a deeply nested token header reaches json.loads. As a result, RecursionError escapes the documented PyJWT error hierarchy. Consequently, an unauthenticated malformed token can cause a request-level failure and HTTP 500. This issue is fixed in version 2.14.0."
}
],
"id": "CVE-2026-102265",
"lastModified": "2026-09-30T19:38:27.293",
"metrics": {
"cvssMetricV31": [
{
"cvssData": {
"attackComplexity": "LOW",
"attackVector": "NETWORK",
"availabilityImpact": "LOW",
"baseScore": 5.3,
"baseSeverity": "MEDIUM",
"confidentialityImpact": "NONE",
"integrityImpact": "NONE",
"privilegesRequired": "NONE",
"scope": "UNCHANGED",
"userInteraction": "NONE",
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L",
"version": "3.1"
},
"exploitabilityScore": 3.9,
"impactScore": 1.4,
"source": "security-advisories@github.com",
"type": "Secondary"
}
]
},
"published": "2026-09-28T21:17:14.077",
"references": [
{
"source": "security-advisories@github.com",
"url": "https://github.com/jpadilla/pyjwt/commit/06573692ebcdec8831c3927513b3e87c31fbbb62"
},
{
"source": "security-advisories@github.com",
"url": "https://github.com/jpadilla/pyjwt/releases/tag/2.14.0"
},
{
"source": "security-advisories@github.com",
"url": "https://github.com/jpadilla/pyjwt/security/advisories/GHSA-8wjv-2p76-3863"
}
],
"sourceIdentifier": "security-advisories@github.com",
"vulnStatus": "Awaiting Analysis",
"weaknesses": [
{
"description": [
{
"lang": "en",
"value": "CWE-674"
}
],
"source": "security-advisories@github.com",
"type": "Primary"
}
]
}
},
"redhat_vex": {
"aggregate_severity": "Moderate",
"current_release_date": "2026-09-30T15:14:24+00:00",
"cve": "CVE-2026-102265",
"id": "CVE-2026-102265",
"initial_release_date": "2026-09-28T20:48:04.664000+00:00",
"product_status:known_affected": "199",
"product_status:known_not_affected": "37",
"product_status:under_investigation": "10",
"source": "Red Hat CSAF VEX",
"status": "final",
"title": "pyjwt: pyjwt: Denial of Service via deeply nested token headers",
"url": "https://security.access.redhat.com/data/csaf/v2/vex/2026/cve-2026-102265.json",
"version": "3"
}
}
}
BREW-MCP-PROXY-CVE-2026-102265 (GHSA-8WJV-2P76-3863)
Vulnerability from osv_homebrew – Published: 2026-09-30 10:08 – Updated: 2026-09-30 10:08 – Source websitePackage
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
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.
{
"affected": [
{
"ecosystem_specific": {
"fix": null,
"range_state": "affected",
"resource": "pyjwt",
"resource_purl": "pkg:pypi/pyjwt@2.13.0",
"upstream_fixed_in": "2.14.0"
},
"package": {
"ecosystem": "Homebrew",
"name": "mcp-proxy",
"purl": "pkg:brew/mcp-proxy"
},
"ranges": [
{
"events": [
{
"introduced": "0.12.0_1"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"database_specific": {
"confidence": "high",
"source": "matched",
"strategy": "registry",
"upstream_evidence": [
{
"ecosystem": "PyPI",
"key": "pkg:pypi/pyjwt@2.13.0",
"name": "pyjwt",
"resource": "pyjwt",
"strategy": "registry",
"subject_version": "2.13.0"
}
]
},
"details": "## Package\n\npyjwt (PyPI)\n\n## Affected versions\n\ntested \u0026 verified on: 2.13.0. Every version whose `PyJWS._load()` translates only `ValueError` is affected\n\n## Description\n\n`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=[...])`.\n\nCPython\u0027s 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.\n\nScope: 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.\n\n## Proof of concept\n\nAgainst any endpoint that validates a token from its JSON body with the documented pattern:\n\n```python\nimport base64, json, http.client\n\ndef b64url(b): return base64.urlsafe_b64encode(b).rstrip(b\"=\")\n\n# unsigned token: the header segment is 200,000 nested \u0027[\u0027 (266,681 bytes total)\ntoken = (b64url(b\"[\" * 200_000) + b\".\" + b64url(b\u0027{\"a\":1}\u0027) + b\".\" + b64url(b\"x\")).decode()\n\nconn = http.client.HTTPConnection(\"127.0.0.1\", 8125, timeout=30)\nconn.request(\"POST\", \"/api/protected\", body=json.dumps({\"token\": token}),\n headers={\"Content-Type\": \"application/json\"})\nprint(conn.getresponse().status) # 500, expected 401\n```\n\nDeterminism: 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.\n\n## Impact\n\nAn 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).\n\n## Fix\n\nCatch `RecursionError` alongside `ValueError` in `_load()` and raise `DecodeError`, or parse the header with a nonrecursive, depthbounded parser.\n\n## Reproduction\n\nA standalone package (docker-compose.yml, Dockerfile, requirements.txt, target app, driver) accompanies this report:\n\n```\ncd \u003cpackage dir\u003e\ndocker compose up -d --build\npython3 poc_check_then_attack.py\ndocker compose down -v\n```\n[poc_pyjwt_recursion.zip](https://github.com/user-attachments/files/31614987/poc_pyjwt_recursion.zip)\n\nExit 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\u0027s `run_log.txt` documents a complete proof run.\n\n## Credits\n\n- @Nivid42\n\n## Maintainer update \u2014 2026-09-09\n\nWe 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\u0027s normal `except PyJWTError` handling.\n\nThe 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.\n\nNo release containing the fix has been published yet, so the patched version remains unset pending release planning. The advisory remains medium severity and unpublished.\n\n## Maintainer update \u2014 2026-09-11\n\nThe 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.",
"id": "BREW-mcp-proxy-CVE-2026-102265",
"modified": "2026-09-30T10:08:19Z",
"published": "2026-09-30T10:08:19Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/jpadilla/pyjwt/security/advisories/GHSA-8wjv-2p76-3863"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-102265"
},
{
"type": "WEB",
"url": "https://github.com/jpadilla/pyjwt/commit/06573692ebcdec8831c3927513b3e87c31fbbb62"
},
{
"type": "PACKAGE",
"url": "https://github.com/jpadilla/pyjwt"
},
{
"type": "WEB",
"url": "https://github.com/jpadilla/pyjwt/releases/tag/2.14.0"
}
],
"schema_version": "1.7.3",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L",
"type": "CVSS_V3"
}
],
"summary": "PyJWT: Uncaught RecursionError in jwt.decode() on deeply nested token header",
"upstream": [
"GHSA-8wjv-2p76-3863",
"CVE-2026-102265"
]
}
BREW-MISTRAL-VIBE-CVE-20… (GHSA-8WJV-2P76-3863)
Vulnerability from osv_homebrew – Published: 2026-09-30 10:09 – Updated: 2026-09-30 10:09 – Source websitePackage
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
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.
{
"affected": [
{
"ecosystem_specific": {
"fix": null,
"range_state": "affected",
"resource": "pyjwt",
"resource_purl": "pkg:pypi/pyjwt@2.13.0",
"upstream_fixed_in": "2.14.0"
},
"package": {
"ecosystem": "Homebrew",
"name": "mistral-vibe",
"purl": "pkg:brew/mistral-vibe"
},
"ranges": [
{
"events": [
{
"introduced": "2.17.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"database_specific": {
"confidence": "high",
"source": "matched",
"strategy": "registry",
"upstream_evidence": [
{
"ecosystem": "PyPI",
"key": "pkg:pypi/pyjwt@2.13.0",
"name": "pyjwt",
"resource": "pyjwt",
"strategy": "registry",
"subject_version": "2.13.0"
}
]
},
"details": "## Package\n\npyjwt (PyPI)\n\n## Affected versions\n\ntested \u0026 verified on: 2.13.0. Every version whose `PyJWS._load()` translates only `ValueError` is affected\n\n## Description\n\n`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=[...])`.\n\nCPython\u0027s 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.\n\nScope: 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.\n\n## Proof of concept\n\nAgainst any endpoint that validates a token from its JSON body with the documented pattern:\n\n```python\nimport base64, json, http.client\n\ndef b64url(b): return base64.urlsafe_b64encode(b).rstrip(b\"=\")\n\n# unsigned token: the header segment is 200,000 nested \u0027[\u0027 (266,681 bytes total)\ntoken = (b64url(b\"[\" * 200_000) + b\".\" + b64url(b\u0027{\"a\":1}\u0027) + b\".\" + b64url(b\"x\")).decode()\n\nconn = http.client.HTTPConnection(\"127.0.0.1\", 8125, timeout=30)\nconn.request(\"POST\", \"/api/protected\", body=json.dumps({\"token\": token}),\n headers={\"Content-Type\": \"application/json\"})\nprint(conn.getresponse().status) # 500, expected 401\n```\n\nDeterminism: 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.\n\n## Impact\n\nAn 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).\n\n## Fix\n\nCatch `RecursionError` alongside `ValueError` in `_load()` and raise `DecodeError`, or parse the header with a nonrecursive, depthbounded parser.\n\n## Reproduction\n\nA standalone package (docker-compose.yml, Dockerfile, requirements.txt, target app, driver) accompanies this report:\n\n```\ncd \u003cpackage dir\u003e\ndocker compose up -d --build\npython3 poc_check_then_attack.py\ndocker compose down -v\n```\n[poc_pyjwt_recursion.zip](https://github.com/user-attachments/files/31614987/poc_pyjwt_recursion.zip)\n\nExit 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\u0027s `run_log.txt` documents a complete proof run.\n\n## Credits\n\n- @Nivid42\n\n## Maintainer update \u2014 2026-09-09\n\nWe 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\u0027s normal `except PyJWTError` handling.\n\nThe 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.\n\nNo release containing the fix has been published yet, so the patched version remains unset pending release planning. The advisory remains medium severity and unpublished.\n\n## Maintainer update \u2014 2026-09-11\n\nThe 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.",
"id": "BREW-mistral-vibe-CVE-2026-102265",
"modified": "2026-09-30T10:09:38Z",
"published": "2026-09-30T10:09:38Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/jpadilla/pyjwt/security/advisories/GHSA-8wjv-2p76-3863"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-102265"
},
{
"type": "WEB",
"url": "https://github.com/jpadilla/pyjwt/commit/06573692ebcdec8831c3927513b3e87c31fbbb62"
},
{
"type": "PACKAGE",
"url": "https://github.com/jpadilla/pyjwt"
},
{
"type": "WEB",
"url": "https://github.com/jpadilla/pyjwt/releases/tag/2.14.0"
}
],
"schema_version": "1.7.3",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L",
"type": "CVSS_V3"
}
],
"summary": "PyJWT: Uncaught RecursionError in jwt.decode() on deeply nested token header",
"upstream": [
"GHSA-8wjv-2p76-3863",
"CVE-2026-102265"
]
}
BREW-OCI-CLI-CVE-2026-102265 (GHSA-8WJV-2P76-3863)
Vulnerability from osv_homebrew – Published: 2026-09-30 08:58 – Updated: 2026-09-30 08:58 – Source websitePackage
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
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.
{
"affected": [
{
"ecosystem_specific": {
"fix": "bump",
"range_state": "fixed",
"resource": "pyjwt",
"resource_purl": "pkg:pypi/pyjwt@2.14.0",
"upstream_fixed_in": "2.14.0"
},
"package": {
"ecosystem": "Homebrew",
"name": "oci-cli",
"purl": "pkg:brew/oci-cli"
},
"ranges": [
{
"events": [
{
"introduced": "3.89.1"
},
{
"fixed": "3.93.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"database_specific": {
"confidence": "high",
"source": "matched",
"strategy": "registry",
"upstream_evidence": [
{
"ecosystem": "PyPI",
"key": "pkg:pypi/pyjwt@2.14.0",
"name": "pyjwt",
"resource": "pyjwt",
"strategy": "registry",
"subject_version": "2.14.0"
}
]
},
"details": "## Package\n\npyjwt (PyPI)\n\n## Affected versions\n\ntested \u0026 verified on: 2.13.0. Every version whose `PyJWS._load()` translates only `ValueError` is affected\n\n## Description\n\n`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=[...])`.\n\nCPython\u0027s 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.\n\nScope: 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.\n\n## Proof of concept\n\nAgainst any endpoint that validates a token from its JSON body with the documented pattern:\n\n```python\nimport base64, json, http.client\n\ndef b64url(b): return base64.urlsafe_b64encode(b).rstrip(b\"=\")\n\n# unsigned token: the header segment is 200,000 nested \u0027[\u0027 (266,681 bytes total)\ntoken = (b64url(b\"[\" * 200_000) + b\".\" + b64url(b\u0027{\"a\":1}\u0027) + b\".\" + b64url(b\"x\")).decode()\n\nconn = http.client.HTTPConnection(\"127.0.0.1\", 8125, timeout=30)\nconn.request(\"POST\", \"/api/protected\", body=json.dumps({\"token\": token}),\n headers={\"Content-Type\": \"application/json\"})\nprint(conn.getresponse().status) # 500, expected 401\n```\n\nDeterminism: 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.\n\n## Impact\n\nAn 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).\n\n## Fix\n\nCatch `RecursionError` alongside `ValueError` in `_load()` and raise `DecodeError`, or parse the header with a nonrecursive, depthbounded parser.\n\n## Reproduction\n\nA standalone package (docker-compose.yml, Dockerfile, requirements.txt, target app, driver) accompanies this report:\n\n```\ncd \u003cpackage dir\u003e\ndocker compose up -d --build\npython3 poc_check_then_attack.py\ndocker compose down -v\n```\n[poc_pyjwt_recursion.zip](https://github.com/user-attachments/files/31614987/poc_pyjwt_recursion.zip)\n\nExit 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\u0027s `run_log.txt` documents a complete proof run.\n\n## Credits\n\n- @Nivid42\n\n## Maintainer update \u2014 2026-09-09\n\nWe 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\u0027s normal `except PyJWTError` handling.\n\nThe 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.\n\nNo release containing the fix has been published yet, so the patched version remains unset pending release planning. The advisory remains medium severity and unpublished.\n\n## Maintainer update \u2014 2026-09-11\n\nThe 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.",
"id": "BREW-oci-cli-CVE-2026-102265",
"modified": "2026-09-30T08:58:35Z",
"published": "2026-09-30T08:58:35Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/jpadilla/pyjwt/security/advisories/GHSA-8wjv-2p76-3863"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-102265"
},
{
"type": "WEB",
"url": "https://github.com/jpadilla/pyjwt/commit/06573692ebcdec8831c3927513b3e87c31fbbb62"
},
{
"type": "PACKAGE",
"url": "https://github.com/jpadilla/pyjwt"
},
{
"type": "WEB",
"url": "https://github.com/jpadilla/pyjwt/releases/tag/2.14.0"
}
],
"schema_version": "1.7.3",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L",
"type": "CVSS_V3"
}
],
"summary": "PyJWT: Uncaught RecursionError in jwt.decode() on deeply nested token header",
"upstream": [
"GHSA-8wjv-2p76-3863",
"CVE-2026-102265"
]
}
BREW-OTERM-CVE-2026-102265 (GHSA-8WJV-2P76-3863)
Vulnerability from osv_homebrew – Published: 2026-09-30 09:35 – Updated: 2026-09-30 09:35 – Source websitePackage
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
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.
{
"affected": [
{
"ecosystem_specific": {
"fix": "bump",
"range_state": "fixed",
"resource": "pyjwt",
"resource_purl": "pkg:pypi/pyjwt@2.15.0",
"upstream_fixed_in": "2.14.0"
},
"package": {
"ecosystem": "Homebrew",
"name": "oterm",
"purl": "pkg:brew/oterm"
},
"ranges": [
{
"events": [
{
"introduced": "0.17.2_1"
},
{
"fixed": "0.25.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"database_specific": {
"confidence": "high",
"source": "matched",
"strategy": "registry",
"upstream_evidence": [
{
"ecosystem": "PyPI",
"key": "pkg:pypi/pyjwt@2.15.0",
"name": "pyjwt",
"resource": "pyjwt",
"strategy": "registry",
"subject_version": "2.15.0"
}
]
},
"details": "## Package\n\npyjwt (PyPI)\n\n## Affected versions\n\ntested \u0026 verified on: 2.13.0. Every version whose `PyJWS._load()` translates only `ValueError` is affected\n\n## Description\n\n`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=[...])`.\n\nCPython\u0027s 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.\n\nScope: 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.\n\n## Proof of concept\n\nAgainst any endpoint that validates a token from its JSON body with the documented pattern:\n\n```python\nimport base64, json, http.client\n\ndef b64url(b): return base64.urlsafe_b64encode(b).rstrip(b\"=\")\n\n# unsigned token: the header segment is 200,000 nested \u0027[\u0027 (266,681 bytes total)\ntoken = (b64url(b\"[\" * 200_000) + b\".\" + b64url(b\u0027{\"a\":1}\u0027) + b\".\" + b64url(b\"x\")).decode()\n\nconn = http.client.HTTPConnection(\"127.0.0.1\", 8125, timeout=30)\nconn.request(\"POST\", \"/api/protected\", body=json.dumps({\"token\": token}),\n headers={\"Content-Type\": \"application/json\"})\nprint(conn.getresponse().status) # 500, expected 401\n```\n\nDeterminism: 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.\n\n## Impact\n\nAn 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).\n\n## Fix\n\nCatch `RecursionError` alongside `ValueError` in `_load()` and raise `DecodeError`, or parse the header with a nonrecursive, depthbounded parser.\n\n## Reproduction\n\nA standalone package (docker-compose.yml, Dockerfile, requirements.txt, target app, driver) accompanies this report:\n\n```\ncd \u003cpackage dir\u003e\ndocker compose up -d --build\npython3 poc_check_then_attack.py\ndocker compose down -v\n```\n[poc_pyjwt_recursion.zip](https://github.com/user-attachments/files/31614987/poc_pyjwt_recursion.zip)\n\nExit 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\u0027s `run_log.txt` documents a complete proof run.\n\n## Credits\n\n- @Nivid42\n\n## Maintainer update \u2014 2026-09-09\n\nWe 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\u0027s normal `except PyJWTError` handling.\n\nThe 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.\n\nNo release containing the fix has been published yet, so the patched version remains unset pending release planning. The advisory remains medium severity and unpublished.\n\n## Maintainer update \u2014 2026-09-11\n\nThe 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.",
"id": "BREW-oterm-CVE-2026-102265",
"modified": "2026-09-30T09:35:20Z",
"published": "2026-09-30T09:35:20Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/jpadilla/pyjwt/security/advisories/GHSA-8wjv-2p76-3863"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-102265"
},
{
"type": "WEB",
"url": "https://github.com/jpadilla/pyjwt/commit/06573692ebcdec8831c3927513b3e87c31fbbb62"
},
{
"type": "PACKAGE",
"url": "https://github.com/jpadilla/pyjwt"
},
{
"type": "WEB",
"url": "https://github.com/jpadilla/pyjwt/releases/tag/2.14.0"
}
],
"schema_version": "1.7.3",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L",
"type": "CVSS_V3"
}
],
"summary": "PyJWT: Uncaught RecursionError in jwt.decode() on deeply nested token header",
"upstream": [
"GHSA-8wjv-2p76-3863",
"CVE-2026-102265"
]
}
BREW-RAPID-MLX-CVE-2026-102265 (GHSA-8WJV-2P76-3863)
Vulnerability from osv_homebrew – Published: 2026-09-30 10:08 – Updated: 2026-09-30 10:08 – Source websitePackage
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
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.
{
"affected": [
{
"ecosystem_specific": {
"fix": "bump",
"range_state": "fixed",
"resource": "pyjwt",
"resource_purl": "pkg:pypi/pyjwt@2.15.0",
"upstream_fixed_in": "2.14.0"
},
"package": {
"ecosystem": "Homebrew",
"name": "rapid-mlx",
"purl": "pkg:brew/rapid-mlx"
},
"ranges": [
{
"events": [
{
"introduced": "0.10.12"
},
{
"fixed": "0.14.2"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"database_specific": {
"confidence": "high",
"source": "matched",
"strategy": "registry",
"upstream_evidence": [
{
"ecosystem": "PyPI",
"key": "pkg:pypi/pyjwt@2.15.0",
"name": "pyjwt",
"resource": "pyjwt",
"strategy": "registry",
"subject_version": "2.15.0"
}
]
},
"details": "## Package\n\npyjwt (PyPI)\n\n## Affected versions\n\ntested \u0026 verified on: 2.13.0. Every version whose `PyJWS._load()` translates only `ValueError` is affected\n\n## Description\n\n`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=[...])`.\n\nCPython\u0027s 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.\n\nScope: 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.\n\n## Proof of concept\n\nAgainst any endpoint that validates a token from its JSON body with the documented pattern:\n\n```python\nimport base64, json, http.client\n\ndef b64url(b): return base64.urlsafe_b64encode(b).rstrip(b\"=\")\n\n# unsigned token: the header segment is 200,000 nested \u0027[\u0027 (266,681 bytes total)\ntoken = (b64url(b\"[\" * 200_000) + b\".\" + b64url(b\u0027{\"a\":1}\u0027) + b\".\" + b64url(b\"x\")).decode()\n\nconn = http.client.HTTPConnection(\"127.0.0.1\", 8125, timeout=30)\nconn.request(\"POST\", \"/api/protected\", body=json.dumps({\"token\": token}),\n headers={\"Content-Type\": \"application/json\"})\nprint(conn.getresponse().status) # 500, expected 401\n```\n\nDeterminism: 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.\n\n## Impact\n\nAn 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).\n\n## Fix\n\nCatch `RecursionError` alongside `ValueError` in `_load()` and raise `DecodeError`, or parse the header with a nonrecursive, depthbounded parser.\n\n## Reproduction\n\nA standalone package (docker-compose.yml, Dockerfile, requirements.txt, target app, driver) accompanies this report:\n\n```\ncd \u003cpackage dir\u003e\ndocker compose up -d --build\npython3 poc_check_then_attack.py\ndocker compose down -v\n```\n[poc_pyjwt_recursion.zip](https://github.com/user-attachments/files/31614987/poc_pyjwt_recursion.zip)\n\nExit 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\u0027s `run_log.txt` documents a complete proof run.\n\n## Credits\n\n- @Nivid42\n\n## Maintainer update \u2014 2026-09-09\n\nWe 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\u0027s normal `except PyJWTError` handling.\n\nThe 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.\n\nNo release containing the fix has been published yet, so the patched version remains unset pending release planning. The advisory remains medium severity and unpublished.\n\n## Maintainer update \u2014 2026-09-11\n\nThe 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.",
"id": "BREW-rapid-mlx-CVE-2026-102265",
"modified": "2026-09-30T10:08:51Z",
"published": "2026-09-30T10:08:51Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/jpadilla/pyjwt/security/advisories/GHSA-8wjv-2p76-3863"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-102265"
},
{
"type": "WEB",
"url": "https://github.com/jpadilla/pyjwt/commit/06573692ebcdec8831c3927513b3e87c31fbbb62"
},
{
"type": "PACKAGE",
"url": "https://github.com/jpadilla/pyjwt"
},
{
"type": "WEB",
"url": "https://github.com/jpadilla/pyjwt/releases/tag/2.14.0"
}
],
"schema_version": "1.7.3",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L",
"type": "CVSS_V3"
}
],
"summary": "PyJWT: Uncaught RecursionError in jwt.decode() on deeply nested token header",
"upstream": [
"GHSA-8wjv-2p76-3863",
"CVE-2026-102265"
]
}
BREW-SIGSTORE-CVE-2026-102265 (GHSA-8WJV-2P76-3863)
Vulnerability from osv_homebrew – Published: 2026-09-30 09:42 – Updated: 2026-09-30 09:42 – Source websitePackage
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
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.
{
"affected": [
{
"ecosystem_specific": {
"fix": null,
"range_state": "affected",
"resource": "pyjwt",
"resource_purl": "pkg:pypi/pyjwt@2.13.0",
"upstream_fixed_in": "2.14.0"
},
"package": {
"ecosystem": "Homebrew",
"name": "sigstore",
"purl": "pkg:brew/sigstore"
},
"ranges": [
{
"events": [
{
"introduced": "4.2.0_8"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"database_specific": {
"confidence": "high",
"source": "matched",
"strategy": "registry",
"upstream_evidence": [
{
"ecosystem": "PyPI",
"key": "pkg:pypi/pyjwt@2.13.0",
"name": "pyjwt",
"resource": "pyjwt",
"strategy": "registry",
"subject_version": "2.13.0"
}
]
},
"details": "## Package\n\npyjwt (PyPI)\n\n## Affected versions\n\ntested \u0026 verified on: 2.13.0. Every version whose `PyJWS._load()` translates only `ValueError` is affected\n\n## Description\n\n`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=[...])`.\n\nCPython\u0027s 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.\n\nScope: 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.\n\n## Proof of concept\n\nAgainst any endpoint that validates a token from its JSON body with the documented pattern:\n\n```python\nimport base64, json, http.client\n\ndef b64url(b): return base64.urlsafe_b64encode(b).rstrip(b\"=\")\n\n# unsigned token: the header segment is 200,000 nested \u0027[\u0027 (266,681 bytes total)\ntoken = (b64url(b\"[\" * 200_000) + b\".\" + b64url(b\u0027{\"a\":1}\u0027) + b\".\" + b64url(b\"x\")).decode()\n\nconn = http.client.HTTPConnection(\"127.0.0.1\", 8125, timeout=30)\nconn.request(\"POST\", \"/api/protected\", body=json.dumps({\"token\": token}),\n headers={\"Content-Type\": \"application/json\"})\nprint(conn.getresponse().status) # 500, expected 401\n```\n\nDeterminism: 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.\n\n## Impact\n\nAn 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).\n\n## Fix\n\nCatch `RecursionError` alongside `ValueError` in `_load()` and raise `DecodeError`, or parse the header with a nonrecursive, depthbounded parser.\n\n## Reproduction\n\nA standalone package (docker-compose.yml, Dockerfile, requirements.txt, target app, driver) accompanies this report:\n\n```\ncd \u003cpackage dir\u003e\ndocker compose up -d --build\npython3 poc_check_then_attack.py\ndocker compose down -v\n```\n[poc_pyjwt_recursion.zip](https://github.com/user-attachments/files/31614987/poc_pyjwt_recursion.zip)\n\nExit 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\u0027s `run_log.txt` documents a complete proof run.\n\n## Credits\n\n- @Nivid42\n\n## Maintainer update \u2014 2026-09-09\n\nWe 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\u0027s normal `except PyJWTError` handling.\n\nThe 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.\n\nNo release containing the fix has been published yet, so the patched version remains unset pending release planning. The advisory remains medium severity and unpublished.\n\n## Maintainer update \u2014 2026-09-11\n\nThe 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.",
"id": "BREW-sigstore-CVE-2026-102265",
"modified": "2026-09-30T09:42:17Z",
"published": "2026-09-30T09:42:17Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/jpadilla/pyjwt/security/advisories/GHSA-8wjv-2p76-3863"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-102265"
},
{
"type": "WEB",
"url": "https://github.com/jpadilla/pyjwt/commit/06573692ebcdec8831c3927513b3e87c31fbbb62"
},
{
"type": "PACKAGE",
"url": "https://github.com/jpadilla/pyjwt"
},
{
"type": "WEB",
"url": "https://github.com/jpadilla/pyjwt/releases/tag/2.14.0"
}
],
"schema_version": "1.7.3",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L",
"type": "CVSS_V3"
}
],
"summary": "PyJWT: Uncaught RecursionError in jwt.decode() on deeply nested token header",
"upstream": [
"GHSA-8wjv-2p76-3863",
"CVE-2026-102265"
]
}
BREW-SNOWFLAKE-CLI-CVE-2… (GHSA-8WJV-2P76-3863)
Vulnerability from osv_homebrew – Published: 2026-09-30 09:56 – Updated: 2026-09-30 09:56 – Source websitePackage
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
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.
{
"affected": [
{
"ecosystem_specific": {
"fix": "bump",
"range_state": "fixed",
"resource": "pyjwt",
"resource_purl": "pkg:pypi/pyjwt@2.15.0",
"upstream_fixed_in": "2.14.0"
},
"package": {
"ecosystem": "Homebrew",
"name": "snowflake-cli",
"purl": "pkg:brew/snowflake-cli"
},
"ranges": [
{
"events": [
{
"introduced": "3.19.0"
},
{
"fixed": "3.28.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"database_specific": {
"confidence": "high",
"source": "matched",
"strategy": "registry",
"upstream_evidence": [
{
"ecosystem": "PyPI",
"key": "pkg:pypi/pyjwt@2.15.0",
"name": "pyjwt",
"resource": "pyjwt",
"strategy": "registry",
"subject_version": "2.15.0"
}
]
},
"details": "## Package\n\npyjwt (PyPI)\n\n## Affected versions\n\ntested \u0026 verified on: 2.13.0. Every version whose `PyJWS._load()` translates only `ValueError` is affected\n\n## Description\n\n`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=[...])`.\n\nCPython\u0027s 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.\n\nScope: 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.\n\n## Proof of concept\n\nAgainst any endpoint that validates a token from its JSON body with the documented pattern:\n\n```python\nimport base64, json, http.client\n\ndef b64url(b): return base64.urlsafe_b64encode(b).rstrip(b\"=\")\n\n# unsigned token: the header segment is 200,000 nested \u0027[\u0027 (266,681 bytes total)\ntoken = (b64url(b\"[\" * 200_000) + b\".\" + b64url(b\u0027{\"a\":1}\u0027) + b\".\" + b64url(b\"x\")).decode()\n\nconn = http.client.HTTPConnection(\"127.0.0.1\", 8125, timeout=30)\nconn.request(\"POST\", \"/api/protected\", body=json.dumps({\"token\": token}),\n headers={\"Content-Type\": \"application/json\"})\nprint(conn.getresponse().status) # 500, expected 401\n```\n\nDeterminism: 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.\n\n## Impact\n\nAn 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).\n\n## Fix\n\nCatch `RecursionError` alongside `ValueError` in `_load()` and raise `DecodeError`, or parse the header with a nonrecursive, depthbounded parser.\n\n## Reproduction\n\nA standalone package (docker-compose.yml, Dockerfile, requirements.txt, target app, driver) accompanies this report:\n\n```\ncd \u003cpackage dir\u003e\ndocker compose up -d --build\npython3 poc_check_then_attack.py\ndocker compose down -v\n```\n[poc_pyjwt_recursion.zip](https://github.com/user-attachments/files/31614987/poc_pyjwt_recursion.zip)\n\nExit 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\u0027s `run_log.txt` documents a complete proof run.\n\n## Credits\n\n- @Nivid42\n\n## Maintainer update \u2014 2026-09-09\n\nWe 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\u0027s normal `except PyJWTError` handling.\n\nThe 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.\n\nNo release containing the fix has been published yet, so the patched version remains unset pending release planning. The advisory remains medium severity and unpublished.\n\n## Maintainer update \u2014 2026-09-11\n\nThe 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.",
"id": "BREW-snowflake-cli-CVE-2026-102265",
"modified": "2026-09-30T09:56:05Z",
"published": "2026-09-30T09:56:05Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/jpadilla/pyjwt/security/advisories/GHSA-8wjv-2p76-3863"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-102265"
},
{
"type": "WEB",
"url": "https://github.com/jpadilla/pyjwt/commit/06573692ebcdec8831c3927513b3e87c31fbbb62"
},
{
"type": "PACKAGE",
"url": "https://github.com/jpadilla/pyjwt"
},
{
"type": "WEB",
"url": "https://github.com/jpadilla/pyjwt/releases/tag/2.14.0"
}
],
"schema_version": "1.7.3",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L",
"type": "CVSS_V3"
}
],
"summary": "PyJWT: Uncaught RecursionError in jwt.decode() on deeply nested token header",
"upstream": [
"GHSA-8wjv-2p76-3863",
"CVE-2026-102265"
]
}
BREW-SNYK-AGENT-SCAN-CVE… (GHSA-8WJV-2P76-3863)
Vulnerability from osv_homebrew – Published: 2026-09-30 09:42 – Updated: 2026-09-30 09:42 – Source websitePackage
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
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.
{
"affected": [
{
"ecosystem_specific": {
"fix": "bump",
"range_state": "fixed",
"resource": "pyjwt",
"resource_purl": "pkg:pypi/pyjwt@2.15.0",
"upstream_fixed_in": "2.14.0"
},
"package": {
"ecosystem": "Homebrew",
"name": "snyk-agent-scan",
"purl": "pkg:brew/snyk-agent-scan"
},
"ranges": [
{
"events": [
{
"introduced": "0.5.4_1"
},
{
"fixed": "0.6.4"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"database_specific": {
"confidence": "high",
"source": "matched",
"strategy": "registry",
"upstream_evidence": [
{
"ecosystem": "PyPI",
"key": "pkg:pypi/pyjwt@2.15.0",
"name": "pyjwt",
"resource": "pyjwt",
"strategy": "registry",
"subject_version": "2.15.0"
}
]
},
"details": "## Package\n\npyjwt (PyPI)\n\n## Affected versions\n\ntested \u0026 verified on: 2.13.0. Every version whose `PyJWS._load()` translates only `ValueError` is affected\n\n## Description\n\n`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=[...])`.\n\nCPython\u0027s 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.\n\nScope: 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.\n\n## Proof of concept\n\nAgainst any endpoint that validates a token from its JSON body with the documented pattern:\n\n```python\nimport base64, json, http.client\n\ndef b64url(b): return base64.urlsafe_b64encode(b).rstrip(b\"=\")\n\n# unsigned token: the header segment is 200,000 nested \u0027[\u0027 (266,681 bytes total)\ntoken = (b64url(b\"[\" * 200_000) + b\".\" + b64url(b\u0027{\"a\":1}\u0027) + b\".\" + b64url(b\"x\")).decode()\n\nconn = http.client.HTTPConnection(\"127.0.0.1\", 8125, timeout=30)\nconn.request(\"POST\", \"/api/protected\", body=json.dumps({\"token\": token}),\n headers={\"Content-Type\": \"application/json\"})\nprint(conn.getresponse().status) # 500, expected 401\n```\n\nDeterminism: 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.\n\n## Impact\n\nAn 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).\n\n## Fix\n\nCatch `RecursionError` alongside `ValueError` in `_load()` and raise `DecodeError`, or parse the header with a nonrecursive, depthbounded parser.\n\n## Reproduction\n\nA standalone package (docker-compose.yml, Dockerfile, requirements.txt, target app, driver) accompanies this report:\n\n```\ncd \u003cpackage dir\u003e\ndocker compose up -d --build\npython3 poc_check_then_attack.py\ndocker compose down -v\n```\n[poc_pyjwt_recursion.zip](https://github.com/user-attachments/files/31614987/poc_pyjwt_recursion.zip)\n\nExit 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\u0027s `run_log.txt` documents a complete proof run.\n\n## Credits\n\n- @Nivid42\n\n## Maintainer update \u2014 2026-09-09\n\nWe 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\u0027s normal `except PyJWTError` handling.\n\nThe 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.\n\nNo release containing the fix has been published yet, so the patched version remains unset pending release planning. The advisory remains medium severity and unpublished.\n\n## Maintainer update \u2014 2026-09-11\n\nThe 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.",
"id": "BREW-snyk-agent-scan-CVE-2026-102265",
"modified": "2026-09-30T09:42:19Z",
"published": "2026-09-30T09:42:19Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/jpadilla/pyjwt/security/advisories/GHSA-8wjv-2p76-3863"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-102265"
},
{
"type": "WEB",
"url": "https://github.com/jpadilla/pyjwt/commit/06573692ebcdec8831c3927513b3e87c31fbbb62"
},
{
"type": "PACKAGE",
"url": "https://github.com/jpadilla/pyjwt"
},
{
"type": "WEB",
"url": "https://github.com/jpadilla/pyjwt/releases/tag/2.14.0"
}
],
"schema_version": "1.7.3",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L",
"type": "CVSS_V3"
}
],
"summary": "PyJWT: Uncaught RecursionError in jwt.decode() on deeply nested token header",
"upstream": [
"GHSA-8wjv-2p76-3863",
"CVE-2026-102265"
]
}
BREW-STRANDS-AGENTS-SOPS… (GHSA-8WJV-2P76-3863)
Vulnerability from osv_homebrew – Published: 2026-09-30 09:58 – Updated: 2026-09-30 09:58 – Source websitePackage
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
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.
{
"affected": [
{
"ecosystem_specific": {
"fix": null,
"range_state": "affected",
"resource": "pyjwt",
"resource_purl": "pkg:pypi/pyjwt@2.13.0",
"upstream_fixed_in": "2.14.0"
},
"package": {
"ecosystem": "Homebrew",
"name": "strands-agents-sops",
"purl": "pkg:brew/strands-agents-sops"
},
"ranges": [
{
"events": [
{
"introduced": "1.1.2_3"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"database_specific": {
"confidence": "high",
"source": "matched",
"strategy": "registry",
"upstream_evidence": [
{
"ecosystem": "PyPI",
"key": "pkg:pypi/pyjwt@2.13.0",
"name": "pyjwt",
"resource": "pyjwt",
"strategy": "registry",
"subject_version": "2.13.0"
}
]
},
"details": "## Package\n\npyjwt (PyPI)\n\n## Affected versions\n\ntested \u0026 verified on: 2.13.0. Every version whose `PyJWS._load()` translates only `ValueError` is affected\n\n## Description\n\n`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=[...])`.\n\nCPython\u0027s 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.\n\nScope: 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.\n\n## Proof of concept\n\nAgainst any endpoint that validates a token from its JSON body with the documented pattern:\n\n```python\nimport base64, json, http.client\n\ndef b64url(b): return base64.urlsafe_b64encode(b).rstrip(b\"=\")\n\n# unsigned token: the header segment is 200,000 nested \u0027[\u0027 (266,681 bytes total)\ntoken = (b64url(b\"[\" * 200_000) + b\".\" + b64url(b\u0027{\"a\":1}\u0027) + b\".\" + b64url(b\"x\")).decode()\n\nconn = http.client.HTTPConnection(\"127.0.0.1\", 8125, timeout=30)\nconn.request(\"POST\", \"/api/protected\", body=json.dumps({\"token\": token}),\n headers={\"Content-Type\": \"application/json\"})\nprint(conn.getresponse().status) # 500, expected 401\n```\n\nDeterminism: 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.\n\n## Impact\n\nAn 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).\n\n## Fix\n\nCatch `RecursionError` alongside `ValueError` in `_load()` and raise `DecodeError`, or parse the header with a nonrecursive, depthbounded parser.\n\n## Reproduction\n\nA standalone package (docker-compose.yml, Dockerfile, requirements.txt, target app, driver) accompanies this report:\n\n```\ncd \u003cpackage dir\u003e\ndocker compose up -d --build\npython3 poc_check_then_attack.py\ndocker compose down -v\n```\n[poc_pyjwt_recursion.zip](https://github.com/user-attachments/files/31614987/poc_pyjwt_recursion.zip)\n\nExit 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\u0027s `run_log.txt` documents a complete proof run.\n\n## Credits\n\n- @Nivid42\n\n## Maintainer update \u2014 2026-09-09\n\nWe 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\u0027s normal `except PyJWTError` handling.\n\nThe 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.\n\nNo release containing the fix has been published yet, so the patched version remains unset pending release planning. The advisory remains medium severity and unpublished.\n\n## Maintainer update \u2014 2026-09-11\n\nThe 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.",
"id": "BREW-strands-agents-sops-CVE-2026-102265",
"modified": "2026-09-30T09:58:03Z",
"published": "2026-09-30T09:58:03Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/jpadilla/pyjwt/security/advisories/GHSA-8wjv-2p76-3863"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-102265"
},
{
"type": "WEB",
"url": "https://github.com/jpadilla/pyjwt/commit/06573692ebcdec8831c3927513b3e87c31fbbb62"
},
{
"type": "PACKAGE",
"url": "https://github.com/jpadilla/pyjwt"
},
{
"type": "WEB",
"url": "https://github.com/jpadilla/pyjwt/releases/tag/2.14.0"
}
],
"schema_version": "1.7.3",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L",
"type": "CVSS_V3"
}
],
"summary": "PyJWT: Uncaught RecursionError in jwt.decode() on deeply nested token header",
"upstream": [
"GHSA-8wjv-2p76-3863",
"CVE-2026-102265"
]
}
FKIE_CVE-2026-102265
Vulnerability from fkie_nvd - Published: 2026-09-28 21:17 - Updated: 2026-09-30 19:38| Vendor | Product | Version |
|---|
{
"affected": [
{
"affectedData": [
{
"product": "pyjwt",
"vendor": "jpadilla",
"versions": [
{
"status": "affected",
"version": "\u003e= 2.13.0, \u003c 2.14.0"
}
]
}
],
"source": "security-advisories@github.com"
}
],
"cveTags": [],
"descriptions": [
{
"lang": "en",
"value": "PyJWT is a Python implementation of JSON Web Token standards. From 2.13.0 until 2.14.0, PyJWS._load in jwt/api_jws.py is affected because parser catches ValueError but not RecursionError. This occurs when a deeply nested token header reaches json.loads. As a result, RecursionError escapes the documented PyJWT error hierarchy. Consequently, an unauthenticated malformed token can cause a request-level failure and HTTP 500. This issue is fixed in version 2.14.0."
}
],
"id": "CVE-2026-102265",
"lastModified": "2026-09-30T19:38:27.293",
"metrics": {
"cvssMetricV31": [
{
"cvssData": {
"attackComplexity": "LOW",
"attackVector": "NETWORK",
"availabilityImpact": "LOW",
"baseScore": 5.3,
"baseSeverity": "MEDIUM",
"confidentialityImpact": "NONE",
"integrityImpact": "NONE",
"privilegesRequired": "NONE",
"scope": "UNCHANGED",
"userInteraction": "NONE",
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L",
"version": "3.1"
},
"exploitabilityScore": 3.9,
"impactScore": 1.4,
"source": "security-advisories@github.com",
"type": "Secondary"
}
]
},
"published": "2026-09-28T21:17:14.077",
"references": [
{
"source": "security-advisories@github.com",
"url": "https://github.com/jpadilla/pyjwt/commit/06573692ebcdec8831c3927513b3e87c31fbbb62"
},
{
"source": "security-advisories@github.com",
"url": "https://github.com/jpadilla/pyjwt/releases/tag/2.14.0"
},
{
"source": "security-advisories@github.com",
"url": "https://github.com/jpadilla/pyjwt/security/advisories/GHSA-8wjv-2p76-3863"
}
],
"sourceIdentifier": "security-advisories@github.com",
"vulnStatus": "Awaiting Analysis",
"weaknesses": [
{
"description": [
{
"lang": "en",
"value": "CWE-674"
}
],
"source": "security-advisories@github.com",
"type": "Primary"
}
]
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.