Action not permitted
Modal body text goes here.
Modal Title
Modal Body
CVE-2026-103001 (GCVE-0-2026-103001)
Vulnerability from cvelistv5 – Published: 2026-09-30 21:15 – Updated: 2026-09-30 21:17- CWE-471 - Modification of Assumed-Immutable Data (MAID)
| URL | Tags |
|---|---|
| https://github.com/jpadilla/pyjwt/security/adviso… | x_refsource_CONFIRM |
| https://github.com/jpadilla/pyjwt/issues/679 | x_refsource_MISC |
| https://github.com/jpadilla/pyjwt/commit/0c87c8c8… | x_refsource_MISC |
{
"containers": {
"cna": {
"affected": [
{
"product": "pyjwt",
"vendor": "jpadilla",
"versions": [
{
"status": "affected",
"version": "\u003e= 2.11.0, \u003c= 2.13.0"
}
]
}
],
"descriptions": [
{
"lang": "en",
"value": "PyJWT is a Python implementation of JSON Web Token standards. From 2.11.0 through 2.13.0, PyJWT\u0027s PyJWT._merge_options() method can modify a caller-supplied mutable options mapping when verify_signature is false. If an application reuses that same mapping for a later decode() or decode_complete() call and changes verify_signature to true, the mapping can retain false values for expiration, not-before, issued-at, audience, issuer, subject, and JWT ID checks. A signed token with invalid registered claims can then be accepted without disabling signature verification, but applications that create a fresh options mapping for each call are not affected."
}
],
"metrics": [
{
"cvssV3_1": {
"attackComplexity": "HIGH",
"attackVector": "NETWORK",
"availabilityImpact": "NONE",
"baseScore": 6.5,
"baseSeverity": "MEDIUM",
"confidentialityImpact": "LOW",
"integrityImpact": "HIGH",
"privilegesRequired": "NONE",
"scope": "UNCHANGED",
"userInteraction": "NONE",
"vectorString": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:H/A:N",
"version": "3.1"
}
}
],
"problemTypes": [
{
"descriptions": [
{
"cweId": "CWE-471",
"description": "CWE-471: Modification of Assumed-Immutable Data (MAID)",
"lang": "en",
"type": "CWE"
}
]
}
],
"providerMetadata": {
"dateUpdated": "2026-09-30T21:17:58.116Z",
"orgId": "a0819718-46f1-4df5-94e2-005712e83aaa",
"shortName": "GitHub_M"
},
"references": [
{
"name": "https://github.com/jpadilla/pyjwt/security/advisories/GHSA-gvp8-978c-rx2q",
"tags": [
"x_refsource_CONFIRM"
],
"url": "https://github.com/jpadilla/pyjwt/security/advisories/GHSA-gvp8-978c-rx2q"
},
{
"name": "https://github.com/jpadilla/pyjwt/issues/679",
"tags": [
"x_refsource_MISC"
],
"url": "https://github.com/jpadilla/pyjwt/issues/679"
},
{
"name": "https://github.com/jpadilla/pyjwt/commit/0c87c8c8b1a74cac99ad8115f3050efcb7fbed35",
"tags": [
"x_refsource_MISC"
],
"url": "https://github.com/jpadilla/pyjwt/commit/0c87c8c8b1a74cac99ad8115f3050efcb7fbed35"
}
],
"source": {
"advisory": "GHSA-gvp8-978c-rx2q",
"discovery": "UNKNOWN"
},
"title": "PyJWT.decode() reintroduces options-dict mutation, enabling silent claim-verification bypass on dict reuse"
}
},
"cveMetadata": {
"assignerOrgId": "a0819718-46f1-4df5-94e2-005712e83aaa",
"assignerShortName": "GitHub_M",
"cveId": "CVE-2026-103001",
"datePublished": "2026-09-30T21:15:12.862Z",
"dateReserved": "2026-09-29T20:46:08.335Z",
"dateUpdated": "2026-09-30T21:17:58.116Z",
"state": "PUBLISHED"
},
"dataType": "CVE_RECORD",
"dataVersion": "5.2",
"vulnerability-lookup:meta": {
"epss": {
"cve": "CVE-2026-103001",
"date": "2026-10-02",
"epss": "0.00236",
"percentile": "0.13182"
},
"nvd": {
"cve": {
"affected": [
{
"affectedData": [
{
"product": "pyjwt",
"vendor": "jpadilla",
"versions": [
{
"status": "affected",
"version": "\u003e= 2.11.0, \u003c= 2.13.0"
}
]
}
],
"source": "security-advisories@github.com"
}
],
"cveTags": [],
"descriptions": [
{
"lang": "en",
"value": "PyJWT is a Python implementation of JSON Web Token standards. From 2.11.0 through 2.13.0, PyJWT\u0027s PyJWT._merge_options() method can modify a caller-supplied mutable options mapping when verify_signature is false. If an application reuses that same mapping for a later decode() or decode_complete() call and changes verify_signature to true, the mapping can retain false values for expiration, not-before, issued-at, audience, issuer, subject, and JWT ID checks. A signed token with invalid registered claims can then be accepted without disabling signature verification, but applications that create a fresh options mapping for each call are not affected."
}
],
"id": "CVE-2026-103001",
"lastModified": "2026-10-01T02:17:43.350",
"metrics": {
"cvssMetricV31": [
{
"cvssData": {
"attackComplexity": "HIGH",
"attackVector": "NETWORK",
"availabilityImpact": "NONE",
"baseScore": 6.5,
"baseSeverity": "MEDIUM",
"confidentialityImpact": "LOW",
"integrityImpact": "HIGH",
"privilegesRequired": "NONE",
"scope": "UNCHANGED",
"userInteraction": "NONE",
"vectorString": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:H/A:N",
"version": "3.1"
},
"exploitabilityScore": 2.2,
"impactScore": 4.2,
"source": "security-advisories@github.com",
"type": "Secondary"
}
]
},
"published": "2026-09-30T22:16:33.537",
"references": [
{
"source": "security-advisories@github.com",
"url": "https://github.com/jpadilla/pyjwt/commit/0c87c8c8b1a74cac99ad8115f3050efcb7fbed35"
},
{
"source": "security-advisories@github.com",
"url": "https://github.com/jpadilla/pyjwt/issues/679"
},
{
"source": "security-advisories@github.com",
"url": "https://github.com/jpadilla/pyjwt/security/advisories/GHSA-gvp8-978c-rx2q"
}
],
"sourceIdentifier": "security-advisories@github.com",
"vulnStatus": "Awaiting Analysis",
"weaknesses": [
{
"description": [
{
"lang": "en",
"value": "CWE-471"
}
],
"source": "security-advisories@github.com",
"type": "Primary"
}
]
}
},
"redhat_vex": {
"aggregate_severity": "Moderate",
"current_release_date": "2026-10-01T17:34:25+00:00",
"cve": "CVE-2026-103001",
"id": "CVE-2026-103001",
"initial_release_date": "2026-09-30T21:15:12.862000+00:00",
"product_status:known_not_affected": "4",
"source": "Red Hat CSAF VEX",
"status": "final",
"title": "pyjwt: pyjwt: Token validation bypass via options dictionary mutation",
"url": "https://security.access.redhat.com/data/csaf/v2/vex/2026/cve-2026-103001.json",
"version": "3"
},
"suse_vex": {
"aggregate_severity": "moderate",
"current_release_date": "2026-10-01T15:59:15Z",
"cve": "CVE-2026-103001",
"id": "CVE-2026-103001",
"initial_release_date": "2026-10-01T15:59:15Z",
"product_status:known_affected": "13",
"product_status:known_not_affected": "113",
"source": "SUSE CSAF VEX",
"status": "interim",
"title": "SUSE CVE CVE-2026-103001",
"url": "https://ftp.suse.com/pub/projects/security/csaf-vex/cve-2026-103001.json",
"version": "2"
}
}
}
BREW-MISTRAL-VIBE-CVE-20… (GHSA-GVP8-978C-RX2Q)
Vulnerability from osv_homebrew – Published: 2026-10-01 10:34 – Updated: 2026-10-01 10:34 – Source websiteSummary
PyJWT.decode()/decode_complete() mutates a caller-supplied options dict in place whenever verify_signature is falsy, adding verify_exp/verify_nbf/verify_iat/verify_aud/verify_iss/verify_sub/verify_jti keys directly onto that object. If application code reuses the same options dict across calls (a config object, a module-level constant, a wrapper's self.options), a later call that explicitly sets verify_signature=True for a full, normal verification silently inherits the leftover verify_exp=False etc. from an earlier verify_signature=False call on the same object -- and accepts an expired, wrong-audience, wrong-issuer, or wrong-subject token as valid with no exception. Signature verification itself is unaffected; this is specifically a claims-check bypass.
This is a regression of an already-fixed, closed issue: originally reported in #679 (2021), fixed in #743 (2022, via a shallow copy of options), and silently reintroduced by #1045 (merged 2025-03-05, a typing/logic refactor that dropped the 2022 shallow-copy line). It has been present since #1045 merged, so it likely shipped in several releases already in the wild.
Details
File: jwt/api_jwt.py, PyJWT._merge_options() (called from decode_complete(), which backs both jwt.decode() and jwt.decode_complete()):
def _merge_options(self, options=None):
if options is None:
return self.options
if not options.get("verify_signature", True):
options["verify_exp"] = options.get("verify_exp", False)
options["verify_nbf"] = options.get("verify_nbf", False)
# ...5 more, same pattern -- all written directly onto the caller's dict
return {**self.options, **options}
No dict(options) copy occurs before these writes, unlike the pre-2025-03-05 code (see #743's fix, options = dict(options or {})). Confirmed via gh api repos/jpadilla/pyjwt/commits/29fbfc36 that #1045's diff removes that line. Confirmed still present on current main via a live fetch of jwt/api_jwt.py.
PoC
Two scripts, each run twice (separate processes), identical output both times (except Python object id()), saved locally and available on request:
import jwt, time
INSECURE_OPTIONS = {"verify_signature": False}
secret = "s3cr3t"
expired_token = jwt.encode(
{"exp": int(time.time()) - 3600, "data": "x"}, secret, algorithm="HS256"
)
jwt.decode(expired_token, options=INSECURE_OPTIONS)
print(INSECURE_OPTIONS)
# The dict has been silently mutated with 7 new keys, including verify_exp: False.
INSECURE_OPTIONS["verify_signature"] = True
result = jwt.decode(expired_token, secret, algorithms=["HS256"], options=INSECURE_OPTIONS)
print(result)
# No exception raised. Expected: jwt.ExpiredSignatureError, since verify_signature=True
# was explicitly requested on this call.
Reproduced deterministically across 2 independent process runs.
Impact
Preconditions: the calling application must reuse a mutable options dict object across two or more decode()/decode_complete() calls with different intent (e.g. one "insecure peek" call followed by a "fully verified" call on the same dict, config object, or instance attribute). This is a real, plausible pattern but not universal; it does not affect code that constructs a fresh options dict per call.
When triggered: a signed-but-expired (or wrong-aud/iss/sub/jti) token is silently accepted as fully verified, with no warning or exception. This is an authentication/claims-verification bypass, not a signature-verification bypass.
Scope: regression present since PR #1045 (merged 2025-03-05), affecting every PyJWT release since then up to and including the current 2.13.0.
Suggested fix: restore a shallow copy inside _merge_options() itself (rather than relying on a caller-side copy), so every caller of _merge_options is covered:
def _merge_options(self, options=None):
if options is None:
return self.options
options = dict(options) # never mutate the caller's dict
if not options.get("verify_signature", True):
options.setdefault("verify_exp", False)
options.setdefault("verify_nbf", False)
options.setdefault("verify_iat", False)
options.setdefault("verify_aud", False)
options.setdefault("verify_iss", False)
options.setdefault("verify_sub", False)
options.setdefault("verify_jti", False)
return {**self.options, **options}
Found via a property-based audit (copy/clone independence across widely-used Python libraries); both repro scripts were written and independently re-run before this report.
Maintainer update — 2026-09-08
We reproduced the reported behavior on PyJWT 2.13.0 at source SHA 7144e4534c34810f4525dc4578a32addd8212cff. A caller-supplied mutable options mapping was modified when verify_signature was false; reusing that mapping after re-enabling signature verification could leave registered-claim checks disabled. This is a conditional claim-verification bypass, not a signature-forgery issue.
A narrow fix has been prepared: PyJWT now copies the options mapping before applying defaults. Regression coverage includes decode(), decode_complete(), constructor input preservation, mutable-mapping compatibility, and reuse of an options mapping across verification modes. The focused tests pass, the full suite passes with 369 tests and 4 intentional cryptography-environment skips, and lint/format/type checks pass.
The advisory remains in triage while the fix goes through release planning. The independently verified range in this investigation is 2.13.0; reports of earlier affected versions should include the exact version and reproduction details.
{
"affected": [
{
"ecosystem_specific": {
"fix": null,
"range_state": "affected",
"resource": "pyjwt",
"resource_purl": "pkg:pypi/pyjwt@2.13.0"
},
"package": {
"ecosystem": "Homebrew",
"name": "mistral-vibe",
"purl": "pkg:brew/mistral-vibe"
},
"ranges": [
{
"events": [
{
"introduced": "2.1.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": "### Summary\n\n`PyJWT.decode()`/`decode_complete()` mutates a caller-supplied `options` dict in place whenever `verify_signature` is falsy, adding `verify_exp`/`verify_nbf`/`verify_iat`/`verify_aud`/`verify_iss`/`verify_sub`/`verify_jti` keys directly onto that object. If application code reuses the same `options` dict across calls (a config object, a module-level constant, a wrapper\u0027s `self.options`), a later call that explicitly sets `verify_signature=True` for a full, normal verification silently inherits the leftover `verify_exp=False` etc. from an earlier `verify_signature=False` call on the same object -- and accepts an expired, wrong-audience, wrong-issuer, or wrong-subject token as valid with no exception. Signature verification itself is unaffected; this is specifically a claims-check bypass.\n\nThis is a regression of an already-fixed, closed issue: originally reported in #679 (2021), fixed in #743 (2022, via a shallow copy of `options`), and silently reintroduced by #1045 (merged 2025-03-05, a typing/logic refactor that dropped the 2022 shallow-copy line). It has been present since #1045 merged, so it likely shipped in several releases already in the wild.\n\n### Details\n\nFile: `jwt/api_jwt.py`, `PyJWT._merge_options()` (called from `decode_complete()`, which backs both `jwt.decode()` and `jwt.decode_complete()`):\n\n```python\ndef _merge_options(self, options=None):\n if options is None:\n return self.options\n if not options.get(\"verify_signature\", True):\n options[\"verify_exp\"] = options.get(\"verify_exp\", False)\n options[\"verify_nbf\"] = options.get(\"verify_nbf\", False)\n # ...5 more, same pattern -- all written directly onto the caller\u0027s dict\n return {**self.options, **options}\n```\n\nNo `dict(options)` copy occurs before these writes, unlike the pre-2025-03-05 code (see #743\u0027s fix, `options = dict(options or {})`). Confirmed via `gh api repos/jpadilla/pyjwt/commits/29fbfc36` that #1045\u0027s diff removes that line. Confirmed still present on current `main` via a live fetch of `jwt/api_jwt.py`.\n\n### PoC\n\nTwo scripts, each run twice (separate processes), identical output both times (except Python object `id()`), saved locally and available on request:\n\n```python\nimport jwt, time\n\nINSECURE_OPTIONS = {\"verify_signature\": False}\n\nsecret = \"s3cr3t\"\nexpired_token = jwt.encode(\n {\"exp\": int(time.time()) - 3600, \"data\": \"x\"}, secret, algorithm=\"HS256\"\n)\n\njwt.decode(expired_token, options=INSECURE_OPTIONS)\nprint(INSECURE_OPTIONS)\n# The dict has been silently mutated with 7 new keys, including verify_exp: False.\n\nINSECURE_OPTIONS[\"verify_signature\"] = True\nresult = jwt.decode(expired_token, secret, algorithms=[\"HS256\"], options=INSECURE_OPTIONS)\nprint(result)\n# No exception raised. Expected: jwt.ExpiredSignatureError, since verify_signature=True\n# was explicitly requested on this call.\n```\n\nReproduced deterministically across 2 independent process runs.\n\n### Impact\n\nPreconditions: the calling application must reuse a mutable `options` dict object across two or more `decode()`/`decode_complete()` calls with different intent (e.g. one \"insecure peek\" call followed by a \"fully verified\" call on the same dict, config object, or instance attribute). This is a real, plausible pattern but not universal; it does not affect code that constructs a fresh `options` dict per call.\n\nWhen triggered: a signed-but-expired (or wrong-aud/iss/sub/jti) token is silently accepted as fully verified, with no warning or exception. This is an authentication/claims-verification bypass, not a signature-verification bypass.\n\nScope: regression present since PR #1045 (merged 2025-03-05), affecting every PyJWT release since then up to and including the current 2.13.0.\n\nSuggested fix: restore a shallow copy inside `_merge_options()` itself (rather than relying on a caller-side copy), so every caller of `_merge_options` is covered:\n\n```python\ndef _merge_options(self, options=None):\n if options is None:\n return self.options\n options = dict(options) # never mutate the caller\u0027s dict\n if not options.get(\"verify_signature\", True):\n options.setdefault(\"verify_exp\", False)\n options.setdefault(\"verify_nbf\", False)\n options.setdefault(\"verify_iat\", False)\n options.setdefault(\"verify_aud\", False)\n options.setdefault(\"verify_iss\", False)\n options.setdefault(\"verify_sub\", False)\n options.setdefault(\"verify_jti\", False)\n return {**self.options, **options}\n```\n\nFound via a property-based audit (copy/clone independence across widely-used Python libraries); both repro scripts were written and independently re-run before this report.\n\n## Maintainer update \u2014 2026-09-08\n\nWe reproduced the reported behavior on PyJWT 2.13.0 at source SHA `7144e4534c34810f4525dc4578a32addd8212cff`. A caller-supplied mutable `options` mapping was modified when `verify_signature` was false; reusing that mapping after re-enabling signature verification could leave registered-claim checks disabled. This is a conditional claim-verification bypass, not a signature-forgery issue.\n\nA narrow fix has been prepared: PyJWT now copies the options mapping before applying defaults. Regression coverage includes `decode()`, `decode_complete()`, constructor input preservation, mutable-mapping compatibility, and reuse of an options mapping across verification modes. The focused tests pass, the full suite passes with 369 tests and 4 intentional cryptography-environment skips, and lint/format/type checks pass.\n\nThe advisory remains in triage while the fix goes through release planning. The independently verified range in this investigation is 2.13.0; reports of earlier affected versions should include the exact version and reproduction details.",
"id": "BREW-mistral-vibe-CVE-2026-103001",
"modified": "2026-10-01T10:34:03Z",
"published": "2026-10-01T10:34:03Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/jpadilla/pyjwt/security/advisories/GHSA-gvp8-978c-rx2q"
},
{
"type": "WEB",
"url": "https://github.com/jpadilla/pyjwt/commit/0c87c8c8b1a74cac99ad8115f3050efcb7fbed35"
},
{
"type": "PACKAGE",
"url": "https://github.com/jpadilla/pyjwt"
}
],
"schema_version": "1.7.3",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:H/A:N",
"type": "CVSS_V3"
}
],
"summary": "PyJWT.decode() reintroduces options-dict mutation, enabling silent claim-verification bypass on dict reuse",
"upstream": [
"GHSA-gvp8-978c-rx2q",
"CVE-2026-103001"
]
}
BREW-OCI-CLI-CVE-2026-103001 (GHSA-GVP8-978C-RX2Q)
Vulnerability from osv_homebrew – Published: 2026-10-01 09:06 – Updated: 2026-10-01 09:06 – Source websiteSummary
PyJWT.decode()/decode_complete() mutates a caller-supplied options dict in place whenever verify_signature is falsy, adding verify_exp/verify_nbf/verify_iat/verify_aud/verify_iss/verify_sub/verify_jti keys directly onto that object. If application code reuses the same options dict across calls (a config object, a module-level constant, a wrapper's self.options), a later call that explicitly sets verify_signature=True for a full, normal verification silently inherits the leftover verify_exp=False etc. from an earlier verify_signature=False call on the same object -- and accepts an expired, wrong-audience, wrong-issuer, or wrong-subject token as valid with no exception. Signature verification itself is unaffected; this is specifically a claims-check bypass.
This is a regression of an already-fixed, closed issue: originally reported in #679 (2021), fixed in #743 (2022, via a shallow copy of options), and silently reintroduced by #1045 (merged 2025-03-05, a typing/logic refactor that dropped the 2022 shallow-copy line). It has been present since #1045 merged, so it likely shipped in several releases already in the wild.
Details
File: jwt/api_jwt.py, PyJWT._merge_options() (called from decode_complete(), which backs both jwt.decode() and jwt.decode_complete()):
def _merge_options(self, options=None):
if options is None:
return self.options
if not options.get("verify_signature", True):
options["verify_exp"] = options.get("verify_exp", False)
options["verify_nbf"] = options.get("verify_nbf", False)
# ...5 more, same pattern -- all written directly onto the caller's dict
return {**self.options, **options}
No dict(options) copy occurs before these writes, unlike the pre-2025-03-05 code (see #743's fix, options = dict(options or {})). Confirmed via gh api repos/jpadilla/pyjwt/commits/29fbfc36 that #1045's diff removes that line. Confirmed still present on current main via a live fetch of jwt/api_jwt.py.
PoC
Two scripts, each run twice (separate processes), identical output both times (except Python object id()), saved locally and available on request:
import jwt, time
INSECURE_OPTIONS = {"verify_signature": False}
secret = "s3cr3t"
expired_token = jwt.encode(
{"exp": int(time.time()) - 3600, "data": "x"}, secret, algorithm="HS256"
)
jwt.decode(expired_token, options=INSECURE_OPTIONS)
print(INSECURE_OPTIONS)
# The dict has been silently mutated with 7 new keys, including verify_exp: False.
INSECURE_OPTIONS["verify_signature"] = True
result = jwt.decode(expired_token, secret, algorithms=["HS256"], options=INSECURE_OPTIONS)
print(result)
# No exception raised. Expected: jwt.ExpiredSignatureError, since verify_signature=True
# was explicitly requested on this call.
Reproduced deterministically across 2 independent process runs.
Impact
Preconditions: the calling application must reuse a mutable options dict object across two or more decode()/decode_complete() calls with different intent (e.g. one "insecure peek" call followed by a "fully verified" call on the same dict, config object, or instance attribute). This is a real, plausible pattern but not universal; it does not affect code that constructs a fresh options dict per call.
When triggered: a signed-but-expired (or wrong-aud/iss/sub/jti) token is silently accepted as fully verified, with no warning or exception. This is an authentication/claims-verification bypass, not a signature-verification bypass.
Scope: regression present since PR #1045 (merged 2025-03-05), affecting every PyJWT release since then up to and including the current 2.13.0.
Suggested fix: restore a shallow copy inside _merge_options() itself (rather than relying on a caller-side copy), so every caller of _merge_options is covered:
def _merge_options(self, options=None):
if options is None:
return self.options
options = dict(options) # never mutate the caller's dict
if not options.get("verify_signature", True):
options.setdefault("verify_exp", False)
options.setdefault("verify_nbf", False)
options.setdefault("verify_iat", False)
options.setdefault("verify_aud", False)
options.setdefault("verify_iss", False)
options.setdefault("verify_sub", False)
options.setdefault("verify_jti", False)
return {**self.options, **options}
Found via a property-based audit (copy/clone independence across widely-used Python libraries); both repro scripts were written and independently re-run before this report.
Maintainer update — 2026-09-08
We reproduced the reported behavior on PyJWT 2.13.0 at source SHA 7144e4534c34810f4525dc4578a32addd8212cff. A caller-supplied mutable options mapping was modified when verify_signature was false; reusing that mapping after re-enabling signature verification could leave registered-claim checks disabled. This is a conditional claim-verification bypass, not a signature-forgery issue.
A narrow fix has been prepared: PyJWT now copies the options mapping before applying defaults. Regression coverage includes decode(), decode_complete(), constructor input preservation, mutable-mapping compatibility, and reuse of an options mapping across verification modes. The focused tests pass, the full suite passes with 369 tests and 4 intentional cryptography-environment skips, and lint/format/type checks pass.
The advisory remains in triage while the fix goes through release planning. The independently verified range in this investigation is 2.13.0; reports of earlier affected versions should include the exact version and reproduction details.
{
"affected": [
{
"ecosystem_specific": {
"fix": "bump",
"range_state": "fixed",
"resource": "pyjwt",
"resource_purl": "pkg:pypi/pyjwt@2.15.1"
},
"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.15.1",
"name": "pyjwt",
"resource": "pyjwt",
"strategy": "registry",
"subject_version": "2.15.1"
}
]
},
"details": "### Summary\n\n`PyJWT.decode()`/`decode_complete()` mutates a caller-supplied `options` dict in place whenever `verify_signature` is falsy, adding `verify_exp`/`verify_nbf`/`verify_iat`/`verify_aud`/`verify_iss`/`verify_sub`/`verify_jti` keys directly onto that object. If application code reuses the same `options` dict across calls (a config object, a module-level constant, a wrapper\u0027s `self.options`), a later call that explicitly sets `verify_signature=True` for a full, normal verification silently inherits the leftover `verify_exp=False` etc. from an earlier `verify_signature=False` call on the same object -- and accepts an expired, wrong-audience, wrong-issuer, or wrong-subject token as valid with no exception. Signature verification itself is unaffected; this is specifically a claims-check bypass.\n\nThis is a regression of an already-fixed, closed issue: originally reported in #679 (2021), fixed in #743 (2022, via a shallow copy of `options`), and silently reintroduced by #1045 (merged 2025-03-05, a typing/logic refactor that dropped the 2022 shallow-copy line). It has been present since #1045 merged, so it likely shipped in several releases already in the wild.\n\n### Details\n\nFile: `jwt/api_jwt.py`, `PyJWT._merge_options()` (called from `decode_complete()`, which backs both `jwt.decode()` and `jwt.decode_complete()`):\n\n```python\ndef _merge_options(self, options=None):\n if options is None:\n return self.options\n if not options.get(\"verify_signature\", True):\n options[\"verify_exp\"] = options.get(\"verify_exp\", False)\n options[\"verify_nbf\"] = options.get(\"verify_nbf\", False)\n # ...5 more, same pattern -- all written directly onto the caller\u0027s dict\n return {**self.options, **options}\n```\n\nNo `dict(options)` copy occurs before these writes, unlike the pre-2025-03-05 code (see #743\u0027s fix, `options = dict(options or {})`). Confirmed via `gh api repos/jpadilla/pyjwt/commits/29fbfc36` that #1045\u0027s diff removes that line. Confirmed still present on current `main` via a live fetch of `jwt/api_jwt.py`.\n\n### PoC\n\nTwo scripts, each run twice (separate processes), identical output both times (except Python object `id()`), saved locally and available on request:\n\n```python\nimport jwt, time\n\nINSECURE_OPTIONS = {\"verify_signature\": False}\n\nsecret = \"s3cr3t\"\nexpired_token = jwt.encode(\n {\"exp\": int(time.time()) - 3600, \"data\": \"x\"}, secret, algorithm=\"HS256\"\n)\n\njwt.decode(expired_token, options=INSECURE_OPTIONS)\nprint(INSECURE_OPTIONS)\n# The dict has been silently mutated with 7 new keys, including verify_exp: False.\n\nINSECURE_OPTIONS[\"verify_signature\"] = True\nresult = jwt.decode(expired_token, secret, algorithms=[\"HS256\"], options=INSECURE_OPTIONS)\nprint(result)\n# No exception raised. Expected: jwt.ExpiredSignatureError, since verify_signature=True\n# was explicitly requested on this call.\n```\n\nReproduced deterministically across 2 independent process runs.\n\n### Impact\n\nPreconditions: the calling application must reuse a mutable `options` dict object across two or more `decode()`/`decode_complete()` calls with different intent (e.g. one \"insecure peek\" call followed by a \"fully verified\" call on the same dict, config object, or instance attribute). This is a real, plausible pattern but not universal; it does not affect code that constructs a fresh `options` dict per call.\n\nWhen triggered: a signed-but-expired (or wrong-aud/iss/sub/jti) token is silently accepted as fully verified, with no warning or exception. This is an authentication/claims-verification bypass, not a signature-verification bypass.\n\nScope: regression present since PR #1045 (merged 2025-03-05), affecting every PyJWT release since then up to and including the current 2.13.0.\n\nSuggested fix: restore a shallow copy inside `_merge_options()` itself (rather than relying on a caller-side copy), so every caller of `_merge_options` is covered:\n\n```python\ndef _merge_options(self, options=None):\n if options is None:\n return self.options\n options = dict(options) # never mutate the caller\u0027s dict\n if not options.get(\"verify_signature\", True):\n options.setdefault(\"verify_exp\", False)\n options.setdefault(\"verify_nbf\", False)\n options.setdefault(\"verify_iat\", False)\n options.setdefault(\"verify_aud\", False)\n options.setdefault(\"verify_iss\", False)\n options.setdefault(\"verify_sub\", False)\n options.setdefault(\"verify_jti\", False)\n return {**self.options, **options}\n```\n\nFound via a property-based audit (copy/clone independence across widely-used Python libraries); both repro scripts were written and independently re-run before this report.\n\n## Maintainer update \u2014 2026-09-08\n\nWe reproduced the reported behavior on PyJWT 2.13.0 at source SHA `7144e4534c34810f4525dc4578a32addd8212cff`. A caller-supplied mutable `options` mapping was modified when `verify_signature` was false; reusing that mapping after re-enabling signature verification could leave registered-claim checks disabled. This is a conditional claim-verification bypass, not a signature-forgery issue.\n\nA narrow fix has been prepared: PyJWT now copies the options mapping before applying defaults. Regression coverage includes `decode()`, `decode_complete()`, constructor input preservation, mutable-mapping compatibility, and reuse of an options mapping across verification modes. The focused tests pass, the full suite passes with 369 tests and 4 intentional cryptography-environment skips, and lint/format/type checks pass.\n\nThe advisory remains in triage while the fix goes through release planning. The independently verified range in this investigation is 2.13.0; reports of earlier affected versions should include the exact version and reproduction details.",
"id": "BREW-oci-cli-CVE-2026-103001",
"modified": "2026-10-01T09:06:26Z",
"published": "2026-10-01T09:06:26Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/jpadilla/pyjwt/security/advisories/GHSA-gvp8-978c-rx2q"
},
{
"type": "WEB",
"url": "https://github.com/jpadilla/pyjwt/commit/0c87c8c8b1a74cac99ad8115f3050efcb7fbed35"
},
{
"type": "PACKAGE",
"url": "https://github.com/jpadilla/pyjwt"
}
],
"schema_version": "1.7.3",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:H/A:N",
"type": "CVSS_V3"
}
],
"summary": "PyJWT.decode() reintroduces options-dict mutation, enabling silent claim-verification bypass on dict reuse",
"upstream": [
"GHSA-gvp8-978c-rx2q",
"CVE-2026-103001"
]
}
BREW-OTERM-CVE-2026-103001 (GHSA-GVP8-978C-RX2Q)
Vulnerability from osv_homebrew – Published: 2026-10-01 09:52 – Updated: 2026-10-01 09:52 – Source websiteSummary
PyJWT.decode()/decode_complete() mutates a caller-supplied options dict in place whenever verify_signature is falsy, adding verify_exp/verify_nbf/verify_iat/verify_aud/verify_iss/verify_sub/verify_jti keys directly onto that object. If application code reuses the same options dict across calls (a config object, a module-level constant, a wrapper's self.options), a later call that explicitly sets verify_signature=True for a full, normal verification silently inherits the leftover verify_exp=False etc. from an earlier verify_signature=False call on the same object -- and accepts an expired, wrong-audience, wrong-issuer, or wrong-subject token as valid with no exception. Signature verification itself is unaffected; this is specifically a claims-check bypass.
This is a regression of an already-fixed, closed issue: originally reported in #679 (2021), fixed in #743 (2022, via a shallow copy of options), and silently reintroduced by #1045 (merged 2025-03-05, a typing/logic refactor that dropped the 2022 shallow-copy line). It has been present since #1045 merged, so it likely shipped in several releases already in the wild.
Details
File: jwt/api_jwt.py, PyJWT._merge_options() (called from decode_complete(), which backs both jwt.decode() and jwt.decode_complete()):
def _merge_options(self, options=None):
if options is None:
return self.options
if not options.get("verify_signature", True):
options["verify_exp"] = options.get("verify_exp", False)
options["verify_nbf"] = options.get("verify_nbf", False)
# ...5 more, same pattern -- all written directly onto the caller's dict
return {**self.options, **options}
No dict(options) copy occurs before these writes, unlike the pre-2025-03-05 code (see #743's fix, options = dict(options or {})). Confirmed via gh api repos/jpadilla/pyjwt/commits/29fbfc36 that #1045's diff removes that line. Confirmed still present on current main via a live fetch of jwt/api_jwt.py.
PoC
Two scripts, each run twice (separate processes), identical output both times (except Python object id()), saved locally and available on request:
import jwt, time
INSECURE_OPTIONS = {"verify_signature": False}
secret = "s3cr3t"
expired_token = jwt.encode(
{"exp": int(time.time()) - 3600, "data": "x"}, secret, algorithm="HS256"
)
jwt.decode(expired_token, options=INSECURE_OPTIONS)
print(INSECURE_OPTIONS)
# The dict has been silently mutated with 7 new keys, including verify_exp: False.
INSECURE_OPTIONS["verify_signature"] = True
result = jwt.decode(expired_token, secret, algorithms=["HS256"], options=INSECURE_OPTIONS)
print(result)
# No exception raised. Expected: jwt.ExpiredSignatureError, since verify_signature=True
# was explicitly requested on this call.
Reproduced deterministically across 2 independent process runs.
Impact
Preconditions: the calling application must reuse a mutable options dict object across two or more decode()/decode_complete() calls with different intent (e.g. one "insecure peek" call followed by a "fully verified" call on the same dict, config object, or instance attribute). This is a real, plausible pattern but not universal; it does not affect code that constructs a fresh options dict per call.
When triggered: a signed-but-expired (or wrong-aud/iss/sub/jti) token is silently accepted as fully verified, with no warning or exception. This is an authentication/claims-verification bypass, not a signature-verification bypass.
Scope: regression present since PR #1045 (merged 2025-03-05), affecting every PyJWT release since then up to and including the current 2.13.0.
Suggested fix: restore a shallow copy inside _merge_options() itself (rather than relying on a caller-side copy), so every caller of _merge_options is covered:
def _merge_options(self, options=None):
if options is None:
return self.options
options = dict(options) # never mutate the caller's dict
if not options.get("verify_signature", True):
options.setdefault("verify_exp", False)
options.setdefault("verify_nbf", False)
options.setdefault("verify_iat", False)
options.setdefault("verify_aud", False)
options.setdefault("verify_iss", False)
options.setdefault("verify_sub", False)
options.setdefault("verify_jti", False)
return {**self.options, **options}
Found via a property-based audit (copy/clone independence across widely-used Python libraries); both repro scripts were written and independently re-run before this report.
Maintainer update — 2026-09-08
We reproduced the reported behavior on PyJWT 2.13.0 at source SHA 7144e4534c34810f4525dc4578a32addd8212cff. A caller-supplied mutable options mapping was modified when verify_signature was false; reusing that mapping after re-enabling signature verification could leave registered-claim checks disabled. This is a conditional claim-verification bypass, not a signature-forgery issue.
A narrow fix has been prepared: PyJWT now copies the options mapping before applying defaults. Regression coverage includes decode(), decode_complete(), constructor input preservation, mutable-mapping compatibility, and reuse of an options mapping across verification modes. The focused tests pass, the full suite passes with 369 tests and 4 intentional cryptography-environment skips, and lint/format/type checks pass.
The advisory remains in triage while the fix goes through release planning. The independently verified range in this investigation is 2.13.0; reports of earlier affected versions should include the exact version and reproduction details.
{
"affected": [
{
"ecosystem_specific": {
"fix": "bump",
"range_state": "fixed",
"resource": "pyjwt",
"resource_purl": "pkg:pypi/pyjwt@2.15.0"
},
"package": {
"ecosystem": "Homebrew",
"name": "oterm",
"purl": "pkg:brew/oterm"
},
"ranges": [
{
"events": [
{
"introduced": "0.14.7_4"
},
{
"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": "### Summary\n\n`PyJWT.decode()`/`decode_complete()` mutates a caller-supplied `options` dict in place whenever `verify_signature` is falsy, adding `verify_exp`/`verify_nbf`/`verify_iat`/`verify_aud`/`verify_iss`/`verify_sub`/`verify_jti` keys directly onto that object. If application code reuses the same `options` dict across calls (a config object, a module-level constant, a wrapper\u0027s `self.options`), a later call that explicitly sets `verify_signature=True` for a full, normal verification silently inherits the leftover `verify_exp=False` etc. from an earlier `verify_signature=False` call on the same object -- and accepts an expired, wrong-audience, wrong-issuer, or wrong-subject token as valid with no exception. Signature verification itself is unaffected; this is specifically a claims-check bypass.\n\nThis is a regression of an already-fixed, closed issue: originally reported in #679 (2021), fixed in #743 (2022, via a shallow copy of `options`), and silently reintroduced by #1045 (merged 2025-03-05, a typing/logic refactor that dropped the 2022 shallow-copy line). It has been present since #1045 merged, so it likely shipped in several releases already in the wild.\n\n### Details\n\nFile: `jwt/api_jwt.py`, `PyJWT._merge_options()` (called from `decode_complete()`, which backs both `jwt.decode()` and `jwt.decode_complete()`):\n\n```python\ndef _merge_options(self, options=None):\n if options is None:\n return self.options\n if not options.get(\"verify_signature\", True):\n options[\"verify_exp\"] = options.get(\"verify_exp\", False)\n options[\"verify_nbf\"] = options.get(\"verify_nbf\", False)\n # ...5 more, same pattern -- all written directly onto the caller\u0027s dict\n return {**self.options, **options}\n```\n\nNo `dict(options)` copy occurs before these writes, unlike the pre-2025-03-05 code (see #743\u0027s fix, `options = dict(options or {})`). Confirmed via `gh api repos/jpadilla/pyjwt/commits/29fbfc36` that #1045\u0027s diff removes that line. Confirmed still present on current `main` via a live fetch of `jwt/api_jwt.py`.\n\n### PoC\n\nTwo scripts, each run twice (separate processes), identical output both times (except Python object `id()`), saved locally and available on request:\n\n```python\nimport jwt, time\n\nINSECURE_OPTIONS = {\"verify_signature\": False}\n\nsecret = \"s3cr3t\"\nexpired_token = jwt.encode(\n {\"exp\": int(time.time()) - 3600, \"data\": \"x\"}, secret, algorithm=\"HS256\"\n)\n\njwt.decode(expired_token, options=INSECURE_OPTIONS)\nprint(INSECURE_OPTIONS)\n# The dict has been silently mutated with 7 new keys, including verify_exp: False.\n\nINSECURE_OPTIONS[\"verify_signature\"] = True\nresult = jwt.decode(expired_token, secret, algorithms=[\"HS256\"], options=INSECURE_OPTIONS)\nprint(result)\n# No exception raised. Expected: jwt.ExpiredSignatureError, since verify_signature=True\n# was explicitly requested on this call.\n```\n\nReproduced deterministically across 2 independent process runs.\n\n### Impact\n\nPreconditions: the calling application must reuse a mutable `options` dict object across two or more `decode()`/`decode_complete()` calls with different intent (e.g. one \"insecure peek\" call followed by a \"fully verified\" call on the same dict, config object, or instance attribute). This is a real, plausible pattern but not universal; it does not affect code that constructs a fresh `options` dict per call.\n\nWhen triggered: a signed-but-expired (or wrong-aud/iss/sub/jti) token is silently accepted as fully verified, with no warning or exception. This is an authentication/claims-verification bypass, not a signature-verification bypass.\n\nScope: regression present since PR #1045 (merged 2025-03-05), affecting every PyJWT release since then up to and including the current 2.13.0.\n\nSuggested fix: restore a shallow copy inside `_merge_options()` itself (rather than relying on a caller-side copy), so every caller of `_merge_options` is covered:\n\n```python\ndef _merge_options(self, options=None):\n if options is None:\n return self.options\n options = dict(options) # never mutate the caller\u0027s dict\n if not options.get(\"verify_signature\", True):\n options.setdefault(\"verify_exp\", False)\n options.setdefault(\"verify_nbf\", False)\n options.setdefault(\"verify_iat\", False)\n options.setdefault(\"verify_aud\", False)\n options.setdefault(\"verify_iss\", False)\n options.setdefault(\"verify_sub\", False)\n options.setdefault(\"verify_jti\", False)\n return {**self.options, **options}\n```\n\nFound via a property-based audit (copy/clone independence across widely-used Python libraries); both repro scripts were written and independently re-run before this report.\n\n## Maintainer update \u2014 2026-09-08\n\nWe reproduced the reported behavior on PyJWT 2.13.0 at source SHA `7144e4534c34810f4525dc4578a32addd8212cff`. A caller-supplied mutable `options` mapping was modified when `verify_signature` was false; reusing that mapping after re-enabling signature verification could leave registered-claim checks disabled. This is a conditional claim-verification bypass, not a signature-forgery issue.\n\nA narrow fix has been prepared: PyJWT now copies the options mapping before applying defaults. Regression coverage includes `decode()`, `decode_complete()`, constructor input preservation, mutable-mapping compatibility, and reuse of an options mapping across verification modes. The focused tests pass, the full suite passes with 369 tests and 4 intentional cryptography-environment skips, and lint/format/type checks pass.\n\nThe advisory remains in triage while the fix goes through release planning. The independently verified range in this investigation is 2.13.0; reports of earlier affected versions should include the exact version and reproduction details.",
"id": "BREW-oterm-CVE-2026-103001",
"modified": "2026-10-01T09:52:01Z",
"published": "2026-10-01T09:52:01Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/jpadilla/pyjwt/security/advisories/GHSA-gvp8-978c-rx2q"
},
{
"type": "WEB",
"url": "https://github.com/jpadilla/pyjwt/commit/0c87c8c8b1a74cac99ad8115f3050efcb7fbed35"
},
{
"type": "PACKAGE",
"url": "https://github.com/jpadilla/pyjwt"
}
],
"schema_version": "1.7.3",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:H/A:N",
"type": "CVSS_V3"
}
],
"summary": "PyJWT.decode() reintroduces options-dict mutation, enabling silent claim-verification bypass on dict reuse",
"upstream": [
"GHSA-gvp8-978c-rx2q",
"CVE-2026-103001"
]
}
BREW-PARSEDMARC-CVE-2026… (GHSA-GVP8-978C-RX2Q)
Vulnerability from osv_homebrew – Published: 2026-10-01 10:47 – Updated: 2026-10-01 10:47 – Source websiteSummary
PyJWT.decode()/decode_complete() mutates a caller-supplied options dict in place whenever verify_signature is falsy, adding verify_exp/verify_nbf/verify_iat/verify_aud/verify_iss/verify_sub/verify_jti keys directly onto that object. If application code reuses the same options dict across calls (a config object, a module-level constant, a wrapper's self.options), a later call that explicitly sets verify_signature=True for a full, normal verification silently inherits the leftover verify_exp=False etc. from an earlier verify_signature=False call on the same object -- and accepts an expired, wrong-audience, wrong-issuer, or wrong-subject token as valid with no exception. Signature verification itself is unaffected; this is specifically a claims-check bypass.
This is a regression of an already-fixed, closed issue: originally reported in #679 (2021), fixed in #743 (2022, via a shallow copy of options), and silently reintroduced by #1045 (merged 2025-03-05, a typing/logic refactor that dropped the 2022 shallow-copy line). It has been present since #1045 merged, so it likely shipped in several releases already in the wild.
Details
File: jwt/api_jwt.py, PyJWT._merge_options() (called from decode_complete(), which backs both jwt.decode() and jwt.decode_complete()):
def _merge_options(self, options=None):
if options is None:
return self.options
if not options.get("verify_signature", True):
options["verify_exp"] = options.get("verify_exp", False)
options["verify_nbf"] = options.get("verify_nbf", False)
# ...5 more, same pattern -- all written directly onto the caller's dict
return {**self.options, **options}
No dict(options) copy occurs before these writes, unlike the pre-2025-03-05 code (see #743's fix, options = dict(options or {})). Confirmed via gh api repos/jpadilla/pyjwt/commits/29fbfc36 that #1045's diff removes that line. Confirmed still present on current main via a live fetch of jwt/api_jwt.py.
PoC
Two scripts, each run twice (separate processes), identical output both times (except Python object id()), saved locally and available on request:
import jwt, time
INSECURE_OPTIONS = {"verify_signature": False}
secret = "s3cr3t"
expired_token = jwt.encode(
{"exp": int(time.time()) - 3600, "data": "x"}, secret, algorithm="HS256"
)
jwt.decode(expired_token, options=INSECURE_OPTIONS)
print(INSECURE_OPTIONS)
# The dict has been silently mutated with 7 new keys, including verify_exp: False.
INSECURE_OPTIONS["verify_signature"] = True
result = jwt.decode(expired_token, secret, algorithms=["HS256"], options=INSECURE_OPTIONS)
print(result)
# No exception raised. Expected: jwt.ExpiredSignatureError, since verify_signature=True
# was explicitly requested on this call.
Reproduced deterministically across 2 independent process runs.
Impact
Preconditions: the calling application must reuse a mutable options dict object across two or more decode()/decode_complete() calls with different intent (e.g. one "insecure peek" call followed by a "fully verified" call on the same dict, config object, or instance attribute). This is a real, plausible pattern but not universal; it does not affect code that constructs a fresh options dict per call.
When triggered: a signed-but-expired (or wrong-aud/iss/sub/jti) token is silently accepted as fully verified, with no warning or exception. This is an authentication/claims-verification bypass, not a signature-verification bypass.
Scope: regression present since PR #1045 (merged 2025-03-05), affecting every PyJWT release since then up to and including the current 2.13.0.
Suggested fix: restore a shallow copy inside _merge_options() itself (rather than relying on a caller-side copy), so every caller of _merge_options is covered:
def _merge_options(self, options=None):
if options is None:
return self.options
options = dict(options) # never mutate the caller's dict
if not options.get("verify_signature", True):
options.setdefault("verify_exp", False)
options.setdefault("verify_nbf", False)
options.setdefault("verify_iat", False)
options.setdefault("verify_aud", False)
options.setdefault("verify_iss", False)
options.setdefault("verify_sub", False)
options.setdefault("verify_jti", False)
return {**self.options, **options}
Found via a property-based audit (copy/clone independence across widely-used Python libraries); both repro scripts were written and independently re-run before this report.
Maintainer update — 2026-09-08
We reproduced the reported behavior on PyJWT 2.13.0 at source SHA 7144e4534c34810f4525dc4578a32addd8212cff. A caller-supplied mutable options mapping was modified when verify_signature was false; reusing that mapping after re-enabling signature verification could leave registered-claim checks disabled. This is a conditional claim-verification bypass, not a signature-forgery issue.
A narrow fix has been prepared: PyJWT now copies the options mapping before applying defaults. Regression coverage includes decode(), decode_complete(), constructor input preservation, mutable-mapping compatibility, and reuse of an options mapping across verification modes. The focused tests pass, the full suite passes with 369 tests and 4 intentional cryptography-environment skips, and lint/format/type checks pass.
The advisory remains in triage while the fix goes through release planning. The independently verified range in this investigation is 2.13.0; reports of earlier affected versions should include the exact version and reproduction details.
{
"affected": [
{
"ecosystem_specific": {
"fix": "bump",
"range_state": "fixed",
"resource": "pyjwt",
"resource_purl": "pkg:pypi/pyjwt@2.15.1"
},
"package": {
"ecosystem": "Homebrew",
"name": "parsedmarc",
"purl": "pkg:brew/parsedmarc"
},
"ranges": [
{
"events": [
{
"introduced": "9.1.0"
},
{
"fixed": "11.0.2"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"database_specific": {
"confidence": "high",
"source": "matched",
"strategy": "registry",
"upstream_evidence": [
{
"ecosystem": "PyPI",
"key": "pkg:pypi/pyjwt@2.15.1",
"name": "pyjwt",
"resource": "pyjwt",
"strategy": "registry",
"subject_version": "2.15.1"
}
]
},
"details": "### Summary\n\n`PyJWT.decode()`/`decode_complete()` mutates a caller-supplied `options` dict in place whenever `verify_signature` is falsy, adding `verify_exp`/`verify_nbf`/`verify_iat`/`verify_aud`/`verify_iss`/`verify_sub`/`verify_jti` keys directly onto that object. If application code reuses the same `options` dict across calls (a config object, a module-level constant, a wrapper\u0027s `self.options`), a later call that explicitly sets `verify_signature=True` for a full, normal verification silently inherits the leftover `verify_exp=False` etc. from an earlier `verify_signature=False` call on the same object -- and accepts an expired, wrong-audience, wrong-issuer, or wrong-subject token as valid with no exception. Signature verification itself is unaffected; this is specifically a claims-check bypass.\n\nThis is a regression of an already-fixed, closed issue: originally reported in #679 (2021), fixed in #743 (2022, via a shallow copy of `options`), and silently reintroduced by #1045 (merged 2025-03-05, a typing/logic refactor that dropped the 2022 shallow-copy line). It has been present since #1045 merged, so it likely shipped in several releases already in the wild.\n\n### Details\n\nFile: `jwt/api_jwt.py`, `PyJWT._merge_options()` (called from `decode_complete()`, which backs both `jwt.decode()` and `jwt.decode_complete()`):\n\n```python\ndef _merge_options(self, options=None):\n if options is None:\n return self.options\n if not options.get(\"verify_signature\", True):\n options[\"verify_exp\"] = options.get(\"verify_exp\", False)\n options[\"verify_nbf\"] = options.get(\"verify_nbf\", False)\n # ...5 more, same pattern -- all written directly onto the caller\u0027s dict\n return {**self.options, **options}\n```\n\nNo `dict(options)` copy occurs before these writes, unlike the pre-2025-03-05 code (see #743\u0027s fix, `options = dict(options or {})`). Confirmed via `gh api repos/jpadilla/pyjwt/commits/29fbfc36` that #1045\u0027s diff removes that line. Confirmed still present on current `main` via a live fetch of `jwt/api_jwt.py`.\n\n### PoC\n\nTwo scripts, each run twice (separate processes), identical output both times (except Python object `id()`), saved locally and available on request:\n\n```python\nimport jwt, time\n\nINSECURE_OPTIONS = {\"verify_signature\": False}\n\nsecret = \"s3cr3t\"\nexpired_token = jwt.encode(\n {\"exp\": int(time.time()) - 3600, \"data\": \"x\"}, secret, algorithm=\"HS256\"\n)\n\njwt.decode(expired_token, options=INSECURE_OPTIONS)\nprint(INSECURE_OPTIONS)\n# The dict has been silently mutated with 7 new keys, including verify_exp: False.\n\nINSECURE_OPTIONS[\"verify_signature\"] = True\nresult = jwt.decode(expired_token, secret, algorithms=[\"HS256\"], options=INSECURE_OPTIONS)\nprint(result)\n# No exception raised. Expected: jwt.ExpiredSignatureError, since verify_signature=True\n# was explicitly requested on this call.\n```\n\nReproduced deterministically across 2 independent process runs.\n\n### Impact\n\nPreconditions: the calling application must reuse a mutable `options` dict object across two or more `decode()`/`decode_complete()` calls with different intent (e.g. one \"insecure peek\" call followed by a \"fully verified\" call on the same dict, config object, or instance attribute). This is a real, plausible pattern but not universal; it does not affect code that constructs a fresh `options` dict per call.\n\nWhen triggered: a signed-but-expired (or wrong-aud/iss/sub/jti) token is silently accepted as fully verified, with no warning or exception. This is an authentication/claims-verification bypass, not a signature-verification bypass.\n\nScope: regression present since PR #1045 (merged 2025-03-05), affecting every PyJWT release since then up to and including the current 2.13.0.\n\nSuggested fix: restore a shallow copy inside `_merge_options()` itself (rather than relying on a caller-side copy), so every caller of `_merge_options` is covered:\n\n```python\ndef _merge_options(self, options=None):\n if options is None:\n return self.options\n options = dict(options) # never mutate the caller\u0027s dict\n if not options.get(\"verify_signature\", True):\n options.setdefault(\"verify_exp\", False)\n options.setdefault(\"verify_nbf\", False)\n options.setdefault(\"verify_iat\", False)\n options.setdefault(\"verify_aud\", False)\n options.setdefault(\"verify_iss\", False)\n options.setdefault(\"verify_sub\", False)\n options.setdefault(\"verify_jti\", False)\n return {**self.options, **options}\n```\n\nFound via a property-based audit (copy/clone independence across widely-used Python libraries); both repro scripts were written and independently re-run before this report.\n\n## Maintainer update \u2014 2026-09-08\n\nWe reproduced the reported behavior on PyJWT 2.13.0 at source SHA `7144e4534c34810f4525dc4578a32addd8212cff`. A caller-supplied mutable `options` mapping was modified when `verify_signature` was false; reusing that mapping after re-enabling signature verification could leave registered-claim checks disabled. This is a conditional claim-verification bypass, not a signature-forgery issue.\n\nA narrow fix has been prepared: PyJWT now copies the options mapping before applying defaults. Regression coverage includes `decode()`, `decode_complete()`, constructor input preservation, mutable-mapping compatibility, and reuse of an options mapping across verification modes. The focused tests pass, the full suite passes with 369 tests and 4 intentional cryptography-environment skips, and lint/format/type checks pass.\n\nThe advisory remains in triage while the fix goes through release planning. The independently verified range in this investigation is 2.13.0; reports of earlier affected versions should include the exact version and reproduction details.",
"id": "BREW-parsedmarc-CVE-2026-103001",
"modified": "2026-10-01T10:47:46Z",
"published": "2026-10-01T10:47:46Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/jpadilla/pyjwt/security/advisories/GHSA-gvp8-978c-rx2q"
},
{
"type": "WEB",
"url": "https://github.com/jpadilla/pyjwt/commit/0c87c8c8b1a74cac99ad8115f3050efcb7fbed35"
},
{
"type": "PACKAGE",
"url": "https://github.com/jpadilla/pyjwt"
}
],
"schema_version": "1.7.3",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:H/A:N",
"type": "CVSS_V3"
}
],
"summary": "PyJWT.decode() reintroduces options-dict mutation, enabling silent claim-verification bypass on dict reuse",
"upstream": [
"GHSA-gvp8-978c-rx2q",
"CVE-2026-103001"
]
}
BREW-PROWLER-CVE-2026-103001 (GHSA-GVP8-978C-RX2Q)
Vulnerability from osv_homebrew – Published: 2026-10-01 10:14 – Updated: 2026-10-01 10:14 – Source websiteSummary
PyJWT.decode()/decode_complete() mutates a caller-supplied options dict in place whenever verify_signature is falsy, adding verify_exp/verify_nbf/verify_iat/verify_aud/verify_iss/verify_sub/verify_jti keys directly onto that object. If application code reuses the same options dict across calls (a config object, a module-level constant, a wrapper's self.options), a later call that explicitly sets verify_signature=True for a full, normal verification silently inherits the leftover verify_exp=False etc. from an earlier verify_signature=False call on the same object -- and accepts an expired, wrong-audience, wrong-issuer, or wrong-subject token as valid with no exception. Signature verification itself is unaffected; this is specifically a claims-check bypass.
This is a regression of an already-fixed, closed issue: originally reported in #679 (2021), fixed in #743 (2022, via a shallow copy of options), and silently reintroduced by #1045 (merged 2025-03-05, a typing/logic refactor that dropped the 2022 shallow-copy line). It has been present since #1045 merged, so it likely shipped in several releases already in the wild.
Details
File: jwt/api_jwt.py, PyJWT._merge_options() (called from decode_complete(), which backs both jwt.decode() and jwt.decode_complete()):
def _merge_options(self, options=None):
if options is None:
return self.options
if not options.get("verify_signature", True):
options["verify_exp"] = options.get("verify_exp", False)
options["verify_nbf"] = options.get("verify_nbf", False)
# ...5 more, same pattern -- all written directly onto the caller's dict
return {**self.options, **options}
No dict(options) copy occurs before these writes, unlike the pre-2025-03-05 code (see #743's fix, options = dict(options or {})). Confirmed via gh api repos/jpadilla/pyjwt/commits/29fbfc36 that #1045's diff removes that line. Confirmed still present on current main via a live fetch of jwt/api_jwt.py.
PoC
Two scripts, each run twice (separate processes), identical output both times (except Python object id()), saved locally and available on request:
import jwt, time
INSECURE_OPTIONS = {"verify_signature": False}
secret = "s3cr3t"
expired_token = jwt.encode(
{"exp": int(time.time()) - 3600, "data": "x"}, secret, algorithm="HS256"
)
jwt.decode(expired_token, options=INSECURE_OPTIONS)
print(INSECURE_OPTIONS)
# The dict has been silently mutated with 7 new keys, including verify_exp: False.
INSECURE_OPTIONS["verify_signature"] = True
result = jwt.decode(expired_token, secret, algorithms=["HS256"], options=INSECURE_OPTIONS)
print(result)
# No exception raised. Expected: jwt.ExpiredSignatureError, since verify_signature=True
# was explicitly requested on this call.
Reproduced deterministically across 2 independent process runs.
Impact
Preconditions: the calling application must reuse a mutable options dict object across two or more decode()/decode_complete() calls with different intent (e.g. one "insecure peek" call followed by a "fully verified" call on the same dict, config object, or instance attribute). This is a real, plausible pattern but not universal; it does not affect code that constructs a fresh options dict per call.
When triggered: a signed-but-expired (or wrong-aud/iss/sub/jti) token is silently accepted as fully verified, with no warning or exception. This is an authentication/claims-verification bypass, not a signature-verification bypass.
Scope: regression present since PR #1045 (merged 2025-03-05), affecting every PyJWT release since then up to and including the current 2.13.0.
Suggested fix: restore a shallow copy inside _merge_options() itself (rather than relying on a caller-side copy), so every caller of _merge_options is covered:
def _merge_options(self, options=None):
if options is None:
return self.options
options = dict(options) # never mutate the caller's dict
if not options.get("verify_signature", True):
options.setdefault("verify_exp", False)
options.setdefault("verify_nbf", False)
options.setdefault("verify_iat", False)
options.setdefault("verify_aud", False)
options.setdefault("verify_iss", False)
options.setdefault("verify_sub", False)
options.setdefault("verify_jti", False)
return {**self.options, **options}
Found via a property-based audit (copy/clone independence across widely-used Python libraries); both repro scripts were written and independently re-run before this report.
Maintainer update — 2026-09-08
We reproduced the reported behavior on PyJWT 2.13.0 at source SHA 7144e4534c34810f4525dc4578a32addd8212cff. A caller-supplied mutable options mapping was modified when verify_signature was false; reusing that mapping after re-enabling signature verification could leave registered-claim checks disabled. This is a conditional claim-verification bypass, not a signature-forgery issue.
A narrow fix has been prepared: PyJWT now copies the options mapping before applying defaults. Regression coverage includes decode(), decode_complete(), constructor input preservation, mutable-mapping compatibility, and reuse of an options mapping across verification modes. The focused tests pass, the full suite passes with 369 tests and 4 intentional cryptography-environment skips, and lint/format/type checks pass.
The advisory remains in triage while the fix goes through release planning. The independently verified range in this investigation is 2.13.0; reports of earlier affected versions should include the exact version and reproduction details.
{
"affected": [
{
"ecosystem_specific": {
"fix": "bump",
"range_state": "fixed",
"resource": "pyjwt",
"resource_purl": "pkg:pypi/pyjwt@2.15.1"
},
"package": {
"ecosystem": "Homebrew",
"name": "prowler",
"purl": "pkg:brew/prowler"
},
"ranges": [
{
"events": [
{
"introduced": "5.18.0"
},
{
"fixed": "5.43.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"database_specific": {
"confidence": "high",
"source": "matched",
"strategy": "registry",
"upstream_evidence": [
{
"ecosystem": "PyPI",
"key": "pkg:pypi/pyjwt@2.15.1",
"name": "pyjwt",
"resource": "pyjwt",
"strategy": "registry",
"subject_version": "2.15.1"
}
]
},
"details": "### Summary\n\n`PyJWT.decode()`/`decode_complete()` mutates a caller-supplied `options` dict in place whenever `verify_signature` is falsy, adding `verify_exp`/`verify_nbf`/`verify_iat`/`verify_aud`/`verify_iss`/`verify_sub`/`verify_jti` keys directly onto that object. If application code reuses the same `options` dict across calls (a config object, a module-level constant, a wrapper\u0027s `self.options`), a later call that explicitly sets `verify_signature=True` for a full, normal verification silently inherits the leftover `verify_exp=False` etc. from an earlier `verify_signature=False` call on the same object -- and accepts an expired, wrong-audience, wrong-issuer, or wrong-subject token as valid with no exception. Signature verification itself is unaffected; this is specifically a claims-check bypass.\n\nThis is a regression of an already-fixed, closed issue: originally reported in #679 (2021), fixed in #743 (2022, via a shallow copy of `options`), and silently reintroduced by #1045 (merged 2025-03-05, a typing/logic refactor that dropped the 2022 shallow-copy line). It has been present since #1045 merged, so it likely shipped in several releases already in the wild.\n\n### Details\n\nFile: `jwt/api_jwt.py`, `PyJWT._merge_options()` (called from `decode_complete()`, which backs both `jwt.decode()` and `jwt.decode_complete()`):\n\n```python\ndef _merge_options(self, options=None):\n if options is None:\n return self.options\n if not options.get(\"verify_signature\", True):\n options[\"verify_exp\"] = options.get(\"verify_exp\", False)\n options[\"verify_nbf\"] = options.get(\"verify_nbf\", False)\n # ...5 more, same pattern -- all written directly onto the caller\u0027s dict\n return {**self.options, **options}\n```\n\nNo `dict(options)` copy occurs before these writes, unlike the pre-2025-03-05 code (see #743\u0027s fix, `options = dict(options or {})`). Confirmed via `gh api repos/jpadilla/pyjwt/commits/29fbfc36` that #1045\u0027s diff removes that line. Confirmed still present on current `main` via a live fetch of `jwt/api_jwt.py`.\n\n### PoC\n\nTwo scripts, each run twice (separate processes), identical output both times (except Python object `id()`), saved locally and available on request:\n\n```python\nimport jwt, time\n\nINSECURE_OPTIONS = {\"verify_signature\": False}\n\nsecret = \"s3cr3t\"\nexpired_token = jwt.encode(\n {\"exp\": int(time.time()) - 3600, \"data\": \"x\"}, secret, algorithm=\"HS256\"\n)\n\njwt.decode(expired_token, options=INSECURE_OPTIONS)\nprint(INSECURE_OPTIONS)\n# The dict has been silently mutated with 7 new keys, including verify_exp: False.\n\nINSECURE_OPTIONS[\"verify_signature\"] = True\nresult = jwt.decode(expired_token, secret, algorithms=[\"HS256\"], options=INSECURE_OPTIONS)\nprint(result)\n# No exception raised. Expected: jwt.ExpiredSignatureError, since verify_signature=True\n# was explicitly requested on this call.\n```\n\nReproduced deterministically across 2 independent process runs.\n\n### Impact\n\nPreconditions: the calling application must reuse a mutable `options` dict object across two or more `decode()`/`decode_complete()` calls with different intent (e.g. one \"insecure peek\" call followed by a \"fully verified\" call on the same dict, config object, or instance attribute). This is a real, plausible pattern but not universal; it does not affect code that constructs a fresh `options` dict per call.\n\nWhen triggered: a signed-but-expired (or wrong-aud/iss/sub/jti) token is silently accepted as fully verified, with no warning or exception. This is an authentication/claims-verification bypass, not a signature-verification bypass.\n\nScope: regression present since PR #1045 (merged 2025-03-05), affecting every PyJWT release since then up to and including the current 2.13.0.\n\nSuggested fix: restore a shallow copy inside `_merge_options()` itself (rather than relying on a caller-side copy), so every caller of `_merge_options` is covered:\n\n```python\ndef _merge_options(self, options=None):\n if options is None:\n return self.options\n options = dict(options) # never mutate the caller\u0027s dict\n if not options.get(\"verify_signature\", True):\n options.setdefault(\"verify_exp\", False)\n options.setdefault(\"verify_nbf\", False)\n options.setdefault(\"verify_iat\", False)\n options.setdefault(\"verify_aud\", False)\n options.setdefault(\"verify_iss\", False)\n options.setdefault(\"verify_sub\", False)\n options.setdefault(\"verify_jti\", False)\n return {**self.options, **options}\n```\n\nFound via a property-based audit (copy/clone independence across widely-used Python libraries); both repro scripts were written and independently re-run before this report.\n\n## Maintainer update \u2014 2026-09-08\n\nWe reproduced the reported behavior on PyJWT 2.13.0 at source SHA `7144e4534c34810f4525dc4578a32addd8212cff`. A caller-supplied mutable `options` mapping was modified when `verify_signature` was false; reusing that mapping after re-enabling signature verification could leave registered-claim checks disabled. This is a conditional claim-verification bypass, not a signature-forgery issue.\n\nA narrow fix has been prepared: PyJWT now copies the options mapping before applying defaults. Regression coverage includes `decode()`, `decode_complete()`, constructor input preservation, mutable-mapping compatibility, and reuse of an options mapping across verification modes. The focused tests pass, the full suite passes with 369 tests and 4 intentional cryptography-environment skips, and lint/format/type checks pass.\n\nThe advisory remains in triage while the fix goes through release planning. The independently verified range in this investigation is 2.13.0; reports of earlier affected versions should include the exact version and reproduction details.",
"id": "BREW-prowler-CVE-2026-103001",
"modified": "2026-10-01T10:14:51Z",
"published": "2026-10-01T10:14:51Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/jpadilla/pyjwt/security/advisories/GHSA-gvp8-978c-rx2q"
},
{
"type": "WEB",
"url": "https://github.com/jpadilla/pyjwt/commit/0c87c8c8b1a74cac99ad8115f3050efcb7fbed35"
},
{
"type": "PACKAGE",
"url": "https://github.com/jpadilla/pyjwt"
}
],
"schema_version": "1.7.3",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:H/A:N",
"type": "CVSS_V3"
}
],
"summary": "PyJWT.decode() reintroduces options-dict mutation, enabling silent claim-verification bypass on dict reuse",
"upstream": [
"GHSA-gvp8-978c-rx2q",
"CVE-2026-103001"
]
}
BREW-RAPID-MLX-CVE-2026-103001 (GHSA-GVP8-978C-RX2Q)
Vulnerability from osv_homebrew – Published: 2026-10-01 10:20 – Updated: 2026-10-01 10:20 – Source websiteSummary
PyJWT.decode()/decode_complete() mutates a caller-supplied options dict in place whenever verify_signature is falsy, adding verify_exp/verify_nbf/verify_iat/verify_aud/verify_iss/verify_sub/verify_jti keys directly onto that object. If application code reuses the same options dict across calls (a config object, a module-level constant, a wrapper's self.options), a later call that explicitly sets verify_signature=True for a full, normal verification silently inherits the leftover verify_exp=False etc. from an earlier verify_signature=False call on the same object -- and accepts an expired, wrong-audience, wrong-issuer, or wrong-subject token as valid with no exception. Signature verification itself is unaffected; this is specifically a claims-check bypass.
This is a regression of an already-fixed, closed issue: originally reported in #679 (2021), fixed in #743 (2022, via a shallow copy of options), and silently reintroduced by #1045 (merged 2025-03-05, a typing/logic refactor that dropped the 2022 shallow-copy line). It has been present since #1045 merged, so it likely shipped in several releases already in the wild.
Details
File: jwt/api_jwt.py, PyJWT._merge_options() (called from decode_complete(), which backs both jwt.decode() and jwt.decode_complete()):
def _merge_options(self, options=None):
if options is None:
return self.options
if not options.get("verify_signature", True):
options["verify_exp"] = options.get("verify_exp", False)
options["verify_nbf"] = options.get("verify_nbf", False)
# ...5 more, same pattern -- all written directly onto the caller's dict
return {**self.options, **options}
No dict(options) copy occurs before these writes, unlike the pre-2025-03-05 code (see #743's fix, options = dict(options or {})). Confirmed via gh api repos/jpadilla/pyjwt/commits/29fbfc36 that #1045's diff removes that line. Confirmed still present on current main via a live fetch of jwt/api_jwt.py.
PoC
Two scripts, each run twice (separate processes), identical output both times (except Python object id()), saved locally and available on request:
import jwt, time
INSECURE_OPTIONS = {"verify_signature": False}
secret = "s3cr3t"
expired_token = jwt.encode(
{"exp": int(time.time()) - 3600, "data": "x"}, secret, algorithm="HS256"
)
jwt.decode(expired_token, options=INSECURE_OPTIONS)
print(INSECURE_OPTIONS)
# The dict has been silently mutated with 7 new keys, including verify_exp: False.
INSECURE_OPTIONS["verify_signature"] = True
result = jwt.decode(expired_token, secret, algorithms=["HS256"], options=INSECURE_OPTIONS)
print(result)
# No exception raised. Expected: jwt.ExpiredSignatureError, since verify_signature=True
# was explicitly requested on this call.
Reproduced deterministically across 2 independent process runs.
Impact
Preconditions: the calling application must reuse a mutable options dict object across two or more decode()/decode_complete() calls with different intent (e.g. one "insecure peek" call followed by a "fully verified" call on the same dict, config object, or instance attribute). This is a real, plausible pattern but not universal; it does not affect code that constructs a fresh options dict per call.
When triggered: a signed-but-expired (or wrong-aud/iss/sub/jti) token is silently accepted as fully verified, with no warning or exception. This is an authentication/claims-verification bypass, not a signature-verification bypass.
Scope: regression present since PR #1045 (merged 2025-03-05), affecting every PyJWT release since then up to and including the current 2.13.0.
Suggested fix: restore a shallow copy inside _merge_options() itself (rather than relying on a caller-side copy), so every caller of _merge_options is covered:
def _merge_options(self, options=None):
if options is None:
return self.options
options = dict(options) # never mutate the caller's dict
if not options.get("verify_signature", True):
options.setdefault("verify_exp", False)
options.setdefault("verify_nbf", False)
options.setdefault("verify_iat", False)
options.setdefault("verify_aud", False)
options.setdefault("verify_iss", False)
options.setdefault("verify_sub", False)
options.setdefault("verify_jti", False)
return {**self.options, **options}
Found via a property-based audit (copy/clone independence across widely-used Python libraries); both repro scripts were written and independently re-run before this report.
Maintainer update — 2026-09-08
We reproduced the reported behavior on PyJWT 2.13.0 at source SHA 7144e4534c34810f4525dc4578a32addd8212cff. A caller-supplied mutable options mapping was modified when verify_signature was false; reusing that mapping after re-enabling signature verification could leave registered-claim checks disabled. This is a conditional claim-verification bypass, not a signature-forgery issue.
A narrow fix has been prepared: PyJWT now copies the options mapping before applying defaults. Regression coverage includes decode(), decode_complete(), constructor input preservation, mutable-mapping compatibility, and reuse of an options mapping across verification modes. The focused tests pass, the full suite passes with 369 tests and 4 intentional cryptography-environment skips, and lint/format/type checks pass.
The advisory remains in triage while the fix goes through release planning. The independently verified range in this investigation is 2.13.0; reports of earlier affected versions should include the exact version and reproduction details.
{
"affected": [
{
"ecosystem_specific": {
"fix": "bump",
"range_state": "fixed",
"resource": "pyjwt",
"resource_purl": "pkg:pypi/pyjwt@2.15.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": "### Summary\n\n`PyJWT.decode()`/`decode_complete()` mutates a caller-supplied `options` dict in place whenever `verify_signature` is falsy, adding `verify_exp`/`verify_nbf`/`verify_iat`/`verify_aud`/`verify_iss`/`verify_sub`/`verify_jti` keys directly onto that object. If application code reuses the same `options` dict across calls (a config object, a module-level constant, a wrapper\u0027s `self.options`), a later call that explicitly sets `verify_signature=True` for a full, normal verification silently inherits the leftover `verify_exp=False` etc. from an earlier `verify_signature=False` call on the same object -- and accepts an expired, wrong-audience, wrong-issuer, or wrong-subject token as valid with no exception. Signature verification itself is unaffected; this is specifically a claims-check bypass.\n\nThis is a regression of an already-fixed, closed issue: originally reported in #679 (2021), fixed in #743 (2022, via a shallow copy of `options`), and silently reintroduced by #1045 (merged 2025-03-05, a typing/logic refactor that dropped the 2022 shallow-copy line). It has been present since #1045 merged, so it likely shipped in several releases already in the wild.\n\n### Details\n\nFile: `jwt/api_jwt.py`, `PyJWT._merge_options()` (called from `decode_complete()`, which backs both `jwt.decode()` and `jwt.decode_complete()`):\n\n```python\ndef _merge_options(self, options=None):\n if options is None:\n return self.options\n if not options.get(\"verify_signature\", True):\n options[\"verify_exp\"] = options.get(\"verify_exp\", False)\n options[\"verify_nbf\"] = options.get(\"verify_nbf\", False)\n # ...5 more, same pattern -- all written directly onto the caller\u0027s dict\n return {**self.options, **options}\n```\n\nNo `dict(options)` copy occurs before these writes, unlike the pre-2025-03-05 code (see #743\u0027s fix, `options = dict(options or {})`). Confirmed via `gh api repos/jpadilla/pyjwt/commits/29fbfc36` that #1045\u0027s diff removes that line. Confirmed still present on current `main` via a live fetch of `jwt/api_jwt.py`.\n\n### PoC\n\nTwo scripts, each run twice (separate processes), identical output both times (except Python object `id()`), saved locally and available on request:\n\n```python\nimport jwt, time\n\nINSECURE_OPTIONS = {\"verify_signature\": False}\n\nsecret = \"s3cr3t\"\nexpired_token = jwt.encode(\n {\"exp\": int(time.time()) - 3600, \"data\": \"x\"}, secret, algorithm=\"HS256\"\n)\n\njwt.decode(expired_token, options=INSECURE_OPTIONS)\nprint(INSECURE_OPTIONS)\n# The dict has been silently mutated with 7 new keys, including verify_exp: False.\n\nINSECURE_OPTIONS[\"verify_signature\"] = True\nresult = jwt.decode(expired_token, secret, algorithms=[\"HS256\"], options=INSECURE_OPTIONS)\nprint(result)\n# No exception raised. Expected: jwt.ExpiredSignatureError, since verify_signature=True\n# was explicitly requested on this call.\n```\n\nReproduced deterministically across 2 independent process runs.\n\n### Impact\n\nPreconditions: the calling application must reuse a mutable `options` dict object across two or more `decode()`/`decode_complete()` calls with different intent (e.g. one \"insecure peek\" call followed by a \"fully verified\" call on the same dict, config object, or instance attribute). This is a real, plausible pattern but not universal; it does not affect code that constructs a fresh `options` dict per call.\n\nWhen triggered: a signed-but-expired (or wrong-aud/iss/sub/jti) token is silently accepted as fully verified, with no warning or exception. This is an authentication/claims-verification bypass, not a signature-verification bypass.\n\nScope: regression present since PR #1045 (merged 2025-03-05), affecting every PyJWT release since then up to and including the current 2.13.0.\n\nSuggested fix: restore a shallow copy inside `_merge_options()` itself (rather than relying on a caller-side copy), so every caller of `_merge_options` is covered:\n\n```python\ndef _merge_options(self, options=None):\n if options is None:\n return self.options\n options = dict(options) # never mutate the caller\u0027s dict\n if not options.get(\"verify_signature\", True):\n options.setdefault(\"verify_exp\", False)\n options.setdefault(\"verify_nbf\", False)\n options.setdefault(\"verify_iat\", False)\n options.setdefault(\"verify_aud\", False)\n options.setdefault(\"verify_iss\", False)\n options.setdefault(\"verify_sub\", False)\n options.setdefault(\"verify_jti\", False)\n return {**self.options, **options}\n```\n\nFound via a property-based audit (copy/clone independence across widely-used Python libraries); both repro scripts were written and independently re-run before this report.\n\n## Maintainer update \u2014 2026-09-08\n\nWe reproduced the reported behavior on PyJWT 2.13.0 at source SHA `7144e4534c34810f4525dc4578a32addd8212cff`. A caller-supplied mutable `options` mapping was modified when `verify_signature` was false; reusing that mapping after re-enabling signature verification could leave registered-claim checks disabled. This is a conditional claim-verification bypass, not a signature-forgery issue.\n\nA narrow fix has been prepared: PyJWT now copies the options mapping before applying defaults. Regression coverage includes `decode()`, `decode_complete()`, constructor input preservation, mutable-mapping compatibility, and reuse of an options mapping across verification modes. The focused tests pass, the full suite passes with 369 tests and 4 intentional cryptography-environment skips, and lint/format/type checks pass.\n\nThe advisory remains in triage while the fix goes through release planning. The independently verified range in this investigation is 2.13.0; reports of earlier affected versions should include the exact version and reproduction details.",
"id": "BREW-rapid-mlx-CVE-2026-103001",
"modified": "2026-10-01T10:20:54Z",
"published": "2026-10-01T10:20:54Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/jpadilla/pyjwt/security/advisories/GHSA-gvp8-978c-rx2q"
},
{
"type": "WEB",
"url": "https://github.com/jpadilla/pyjwt/commit/0c87c8c8b1a74cac99ad8115f3050efcb7fbed35"
},
{
"type": "PACKAGE",
"url": "https://github.com/jpadilla/pyjwt"
}
],
"schema_version": "1.7.3",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:H/A:N",
"type": "CVSS_V3"
}
],
"summary": "PyJWT.decode() reintroduces options-dict mutation, enabling silent claim-verification bypass on dict reuse",
"upstream": [
"GHSA-gvp8-978c-rx2q",
"CVE-2026-103001"
]
}
BREW-SCOUTSUITE-CVE-2026… (GHSA-GVP8-978C-RX2Q)
Vulnerability from osv_homebrew – Published: 2026-10-01 11:02 – Updated: 2026-10-01 11:02 – Source websiteSummary
PyJWT.decode()/decode_complete() mutates a caller-supplied options dict in place whenever verify_signature is falsy, adding verify_exp/verify_nbf/verify_iat/verify_aud/verify_iss/verify_sub/verify_jti keys directly onto that object. If application code reuses the same options dict across calls (a config object, a module-level constant, a wrapper's self.options), a later call that explicitly sets verify_signature=True for a full, normal verification silently inherits the leftover verify_exp=False etc. from an earlier verify_signature=False call on the same object -- and accepts an expired, wrong-audience, wrong-issuer, or wrong-subject token as valid with no exception. Signature verification itself is unaffected; this is specifically a claims-check bypass.
This is a regression of an already-fixed, closed issue: originally reported in #679 (2021), fixed in #743 (2022, via a shallow copy of options), and silently reintroduced by #1045 (merged 2025-03-05, a typing/logic refactor that dropped the 2022 shallow-copy line). It has been present since #1045 merged, so it likely shipped in several releases already in the wild.
Details
File: jwt/api_jwt.py, PyJWT._merge_options() (called from decode_complete(), which backs both jwt.decode() and jwt.decode_complete()):
def _merge_options(self, options=None):
if options is None:
return self.options
if not options.get("verify_signature", True):
options["verify_exp"] = options.get("verify_exp", False)
options["verify_nbf"] = options.get("verify_nbf", False)
# ...5 more, same pattern -- all written directly onto the caller's dict
return {**self.options, **options}
No dict(options) copy occurs before these writes, unlike the pre-2025-03-05 code (see #743's fix, options = dict(options or {})). Confirmed via gh api repos/jpadilla/pyjwt/commits/29fbfc36 that #1045's diff removes that line. Confirmed still present on current main via a live fetch of jwt/api_jwt.py.
PoC
Two scripts, each run twice (separate processes), identical output both times (except Python object id()), saved locally and available on request:
import jwt, time
INSECURE_OPTIONS = {"verify_signature": False}
secret = "s3cr3t"
expired_token = jwt.encode(
{"exp": int(time.time()) - 3600, "data": "x"}, secret, algorithm="HS256"
)
jwt.decode(expired_token, options=INSECURE_OPTIONS)
print(INSECURE_OPTIONS)
# The dict has been silently mutated with 7 new keys, including verify_exp: False.
INSECURE_OPTIONS["verify_signature"] = True
result = jwt.decode(expired_token, secret, algorithms=["HS256"], options=INSECURE_OPTIONS)
print(result)
# No exception raised. Expected: jwt.ExpiredSignatureError, since verify_signature=True
# was explicitly requested on this call.
Reproduced deterministically across 2 independent process runs.
Impact
Preconditions: the calling application must reuse a mutable options dict object across two or more decode()/decode_complete() calls with different intent (e.g. one "insecure peek" call followed by a "fully verified" call on the same dict, config object, or instance attribute). This is a real, plausible pattern but not universal; it does not affect code that constructs a fresh options dict per call.
When triggered: a signed-but-expired (or wrong-aud/iss/sub/jti) token is silently accepted as fully verified, with no warning or exception. This is an authentication/claims-verification bypass, not a signature-verification bypass.
Scope: regression present since PR #1045 (merged 2025-03-05), affecting every PyJWT release since then up to and including the current 2.13.0.
Suggested fix: restore a shallow copy inside _merge_options() itself (rather than relying on a caller-side copy), so every caller of _merge_options is covered:
def _merge_options(self, options=None):
if options is None:
return self.options
options = dict(options) # never mutate the caller's dict
if not options.get("verify_signature", True):
options.setdefault("verify_exp", False)
options.setdefault("verify_nbf", False)
options.setdefault("verify_iat", False)
options.setdefault("verify_aud", False)
options.setdefault("verify_iss", False)
options.setdefault("verify_sub", False)
options.setdefault("verify_jti", False)
return {**self.options, **options}
Found via a property-based audit (copy/clone independence across widely-used Python libraries); both repro scripts were written and independently re-run before this report.
Maintainer update — 2026-09-08
We reproduced the reported behavior on PyJWT 2.13.0 at source SHA 7144e4534c34810f4525dc4578a32addd8212cff. A caller-supplied mutable options mapping was modified when verify_signature was false; reusing that mapping after re-enabling signature verification could leave registered-claim checks disabled. This is a conditional claim-verification bypass, not a signature-forgery issue.
A narrow fix has been prepared: PyJWT now copies the options mapping before applying defaults. Regression coverage includes decode(), decode_complete(), constructor input preservation, mutable-mapping compatibility, and reuse of an options mapping across verification modes. The focused tests pass, the full suite passes with 369 tests and 4 intentional cryptography-environment skips, and lint/format/type checks pass.
The advisory remains in triage while the fix goes through release planning. The independently verified range in this investigation is 2.13.0; reports of earlier affected versions should include the exact version and reproduction details.
{
"affected": [
{
"ecosystem_specific": {
"fix": "bump",
"range_state": "fixed",
"resource": "pyjwt",
"resource_purl": "pkg:pypi/pyjwt@2.15.1"
},
"package": {
"ecosystem": "Homebrew",
"name": "scoutsuite",
"purl": "pkg:brew/scoutsuite"
},
"ranges": [
{
"events": [
{
"introduced": "5.14.0_8"
},
{
"fixed": "5.14.0_17"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"database_specific": {
"confidence": "high",
"source": "matched",
"strategy": "registry",
"upstream_evidence": [
{
"ecosystem": "PyPI",
"key": "pkg:pypi/pyjwt@2.15.1",
"name": "pyjwt",
"resource": "pyjwt",
"strategy": "registry",
"subject_version": "2.15.1"
}
]
},
"details": "### Summary\n\n`PyJWT.decode()`/`decode_complete()` mutates a caller-supplied `options` dict in place whenever `verify_signature` is falsy, adding `verify_exp`/`verify_nbf`/`verify_iat`/`verify_aud`/`verify_iss`/`verify_sub`/`verify_jti` keys directly onto that object. If application code reuses the same `options` dict across calls (a config object, a module-level constant, a wrapper\u0027s `self.options`), a later call that explicitly sets `verify_signature=True` for a full, normal verification silently inherits the leftover `verify_exp=False` etc. from an earlier `verify_signature=False` call on the same object -- and accepts an expired, wrong-audience, wrong-issuer, or wrong-subject token as valid with no exception. Signature verification itself is unaffected; this is specifically a claims-check bypass.\n\nThis is a regression of an already-fixed, closed issue: originally reported in #679 (2021), fixed in #743 (2022, via a shallow copy of `options`), and silently reintroduced by #1045 (merged 2025-03-05, a typing/logic refactor that dropped the 2022 shallow-copy line). It has been present since #1045 merged, so it likely shipped in several releases already in the wild.\n\n### Details\n\nFile: `jwt/api_jwt.py`, `PyJWT._merge_options()` (called from `decode_complete()`, which backs both `jwt.decode()` and `jwt.decode_complete()`):\n\n```python\ndef _merge_options(self, options=None):\n if options is None:\n return self.options\n if not options.get(\"verify_signature\", True):\n options[\"verify_exp\"] = options.get(\"verify_exp\", False)\n options[\"verify_nbf\"] = options.get(\"verify_nbf\", False)\n # ...5 more, same pattern -- all written directly onto the caller\u0027s dict\n return {**self.options, **options}\n```\n\nNo `dict(options)` copy occurs before these writes, unlike the pre-2025-03-05 code (see #743\u0027s fix, `options = dict(options or {})`). Confirmed via `gh api repos/jpadilla/pyjwt/commits/29fbfc36` that #1045\u0027s diff removes that line. Confirmed still present on current `main` via a live fetch of `jwt/api_jwt.py`.\n\n### PoC\n\nTwo scripts, each run twice (separate processes), identical output both times (except Python object `id()`), saved locally and available on request:\n\n```python\nimport jwt, time\n\nINSECURE_OPTIONS = {\"verify_signature\": False}\n\nsecret = \"s3cr3t\"\nexpired_token = jwt.encode(\n {\"exp\": int(time.time()) - 3600, \"data\": \"x\"}, secret, algorithm=\"HS256\"\n)\n\njwt.decode(expired_token, options=INSECURE_OPTIONS)\nprint(INSECURE_OPTIONS)\n# The dict has been silently mutated with 7 new keys, including verify_exp: False.\n\nINSECURE_OPTIONS[\"verify_signature\"] = True\nresult = jwt.decode(expired_token, secret, algorithms=[\"HS256\"], options=INSECURE_OPTIONS)\nprint(result)\n# No exception raised. Expected: jwt.ExpiredSignatureError, since verify_signature=True\n# was explicitly requested on this call.\n```\n\nReproduced deterministically across 2 independent process runs.\n\n### Impact\n\nPreconditions: the calling application must reuse a mutable `options` dict object across two or more `decode()`/`decode_complete()` calls with different intent (e.g. one \"insecure peek\" call followed by a \"fully verified\" call on the same dict, config object, or instance attribute). This is a real, plausible pattern but not universal; it does not affect code that constructs a fresh `options` dict per call.\n\nWhen triggered: a signed-but-expired (or wrong-aud/iss/sub/jti) token is silently accepted as fully verified, with no warning or exception. This is an authentication/claims-verification bypass, not a signature-verification bypass.\n\nScope: regression present since PR #1045 (merged 2025-03-05), affecting every PyJWT release since then up to and including the current 2.13.0.\n\nSuggested fix: restore a shallow copy inside `_merge_options()` itself (rather than relying on a caller-side copy), so every caller of `_merge_options` is covered:\n\n```python\ndef _merge_options(self, options=None):\n if options is None:\n return self.options\n options = dict(options) # never mutate the caller\u0027s dict\n if not options.get(\"verify_signature\", True):\n options.setdefault(\"verify_exp\", False)\n options.setdefault(\"verify_nbf\", False)\n options.setdefault(\"verify_iat\", False)\n options.setdefault(\"verify_aud\", False)\n options.setdefault(\"verify_iss\", False)\n options.setdefault(\"verify_sub\", False)\n options.setdefault(\"verify_jti\", False)\n return {**self.options, **options}\n```\n\nFound via a property-based audit (copy/clone independence across widely-used Python libraries); both repro scripts were written and independently re-run before this report.\n\n## Maintainer update \u2014 2026-09-08\n\nWe reproduced the reported behavior on PyJWT 2.13.0 at source SHA `7144e4534c34810f4525dc4578a32addd8212cff`. A caller-supplied mutable `options` mapping was modified when `verify_signature` was false; reusing that mapping after re-enabling signature verification could leave registered-claim checks disabled. This is a conditional claim-verification bypass, not a signature-forgery issue.\n\nA narrow fix has been prepared: PyJWT now copies the options mapping before applying defaults. Regression coverage includes `decode()`, `decode_complete()`, constructor input preservation, mutable-mapping compatibility, and reuse of an options mapping across verification modes. The focused tests pass, the full suite passes with 369 tests and 4 intentional cryptography-environment skips, and lint/format/type checks pass.\n\nThe advisory remains in triage while the fix goes through release planning. The independently verified range in this investigation is 2.13.0; reports of earlier affected versions should include the exact version and reproduction details.",
"id": "BREW-scoutsuite-CVE-2026-103001",
"modified": "2026-10-01T11:02:07Z",
"published": "2026-10-01T11:02:07Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/jpadilla/pyjwt/security/advisories/GHSA-gvp8-978c-rx2q"
},
{
"type": "WEB",
"url": "https://github.com/jpadilla/pyjwt/commit/0c87c8c8b1a74cac99ad8115f3050efcb7fbed35"
},
{
"type": "PACKAGE",
"url": "https://github.com/jpadilla/pyjwt"
}
],
"schema_version": "1.7.3",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:H/A:N",
"type": "CVSS_V3"
}
],
"summary": "PyJWT.decode() reintroduces options-dict mutation, enabling silent claim-verification bypass on dict reuse",
"upstream": [
"GHSA-gvp8-978c-rx2q",
"CVE-2026-103001"
]
}
BREW-SEMGREP-CVE-2026-103001 (GHSA-GVP8-978C-RX2Q)
Vulnerability from osv_homebrew – Published: 2026-10-01 09:45 – Updated: 2026-10-01 09:45 – Source websiteSummary
PyJWT.decode()/decode_complete() mutates a caller-supplied options dict in place whenever verify_signature is falsy, adding verify_exp/verify_nbf/verify_iat/verify_aud/verify_iss/verify_sub/verify_jti keys directly onto that object. If application code reuses the same options dict across calls (a config object, a module-level constant, a wrapper's self.options), a later call that explicitly sets verify_signature=True for a full, normal verification silently inherits the leftover verify_exp=False etc. from an earlier verify_signature=False call on the same object -- and accepts an expired, wrong-audience, wrong-issuer, or wrong-subject token as valid with no exception. Signature verification itself is unaffected; this is specifically a claims-check bypass.
This is a regression of an already-fixed, closed issue: originally reported in #679 (2021), fixed in #743 (2022, via a shallow copy of options), and silently reintroduced by #1045 (merged 2025-03-05, a typing/logic refactor that dropped the 2022 shallow-copy line). It has been present since #1045 merged, so it likely shipped in several releases already in the wild.
Details
File: jwt/api_jwt.py, PyJWT._merge_options() (called from decode_complete(), which backs both jwt.decode() and jwt.decode_complete()):
def _merge_options(self, options=None):
if options is None:
return self.options
if not options.get("verify_signature", True):
options["verify_exp"] = options.get("verify_exp", False)
options["verify_nbf"] = options.get("verify_nbf", False)
# ...5 more, same pattern -- all written directly onto the caller's dict
return {**self.options, **options}
No dict(options) copy occurs before these writes, unlike the pre-2025-03-05 code (see #743's fix, options = dict(options or {})). Confirmed via gh api repos/jpadilla/pyjwt/commits/29fbfc36 that #1045's diff removes that line. Confirmed still present on current main via a live fetch of jwt/api_jwt.py.
PoC
Two scripts, each run twice (separate processes), identical output both times (except Python object id()), saved locally and available on request:
import jwt, time
INSECURE_OPTIONS = {"verify_signature": False}
secret = "s3cr3t"
expired_token = jwt.encode(
{"exp": int(time.time()) - 3600, "data": "x"}, secret, algorithm="HS256"
)
jwt.decode(expired_token, options=INSECURE_OPTIONS)
print(INSECURE_OPTIONS)
# The dict has been silently mutated with 7 new keys, including verify_exp: False.
INSECURE_OPTIONS["verify_signature"] = True
result = jwt.decode(expired_token, secret, algorithms=["HS256"], options=INSECURE_OPTIONS)
print(result)
# No exception raised. Expected: jwt.ExpiredSignatureError, since verify_signature=True
# was explicitly requested on this call.
Reproduced deterministically across 2 independent process runs.
Impact
Preconditions: the calling application must reuse a mutable options dict object across two or more decode()/decode_complete() calls with different intent (e.g. one "insecure peek" call followed by a "fully verified" call on the same dict, config object, or instance attribute). This is a real, plausible pattern but not universal; it does not affect code that constructs a fresh options dict per call.
When triggered: a signed-but-expired (or wrong-aud/iss/sub/jti) token is silently accepted as fully verified, with no warning or exception. This is an authentication/claims-verification bypass, not a signature-verification bypass.
Scope: regression present since PR #1045 (merged 2025-03-05), affecting every PyJWT release since then up to and including the current 2.13.0.
Suggested fix: restore a shallow copy inside _merge_options() itself (rather than relying on a caller-side copy), so every caller of _merge_options is covered:
def _merge_options(self, options=None):
if options is None:
return self.options
options = dict(options) # never mutate the caller's dict
if not options.get("verify_signature", True):
options.setdefault("verify_exp", False)
options.setdefault("verify_nbf", False)
options.setdefault("verify_iat", False)
options.setdefault("verify_aud", False)
options.setdefault("verify_iss", False)
options.setdefault("verify_sub", False)
options.setdefault("verify_jti", False)
return {**self.options, **options}
Found via a property-based audit (copy/clone independence across widely-used Python libraries); both repro scripts were written and independently re-run before this report.
Maintainer update — 2026-09-08
We reproduced the reported behavior on PyJWT 2.13.0 at source SHA 7144e4534c34810f4525dc4578a32addd8212cff. A caller-supplied mutable options mapping was modified when verify_signature was false; reusing that mapping after re-enabling signature verification could leave registered-claim checks disabled. This is a conditional claim-verification bypass, not a signature-forgery issue.
A narrow fix has been prepared: PyJWT now copies the options mapping before applying defaults. Regression coverage includes decode(), decode_complete(), constructor input preservation, mutable-mapping compatibility, and reuse of an options mapping across verification modes. The focused tests pass, the full suite passes with 369 tests and 4 intentional cryptography-environment skips, and lint/format/type checks pass.
The advisory remains in triage while the fix goes through release planning. The independently verified range in this investigation is 2.13.0; reports of earlier affected versions should include the exact version and reproduction details.
{
"affected": [
{
"ecosystem_specific": {
"fix": null,
"range_state": "affected",
"resource": "pyjwt",
"resource_purl": "pkg:pypi/pyjwt@2.13.0"
},
"package": {
"ecosystem": "Homebrew",
"name": "semgrep",
"purl": "pkg:brew/semgrep"
},
"ranges": [
{
"events": [
{
"introduced": "1.151.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": "### Summary\n\n`PyJWT.decode()`/`decode_complete()` mutates a caller-supplied `options` dict in place whenever `verify_signature` is falsy, adding `verify_exp`/`verify_nbf`/`verify_iat`/`verify_aud`/`verify_iss`/`verify_sub`/`verify_jti` keys directly onto that object. If application code reuses the same `options` dict across calls (a config object, a module-level constant, a wrapper\u0027s `self.options`), a later call that explicitly sets `verify_signature=True` for a full, normal verification silently inherits the leftover `verify_exp=False` etc. from an earlier `verify_signature=False` call on the same object -- and accepts an expired, wrong-audience, wrong-issuer, or wrong-subject token as valid with no exception. Signature verification itself is unaffected; this is specifically a claims-check bypass.\n\nThis is a regression of an already-fixed, closed issue: originally reported in #679 (2021), fixed in #743 (2022, via a shallow copy of `options`), and silently reintroduced by #1045 (merged 2025-03-05, a typing/logic refactor that dropped the 2022 shallow-copy line). It has been present since #1045 merged, so it likely shipped in several releases already in the wild.\n\n### Details\n\nFile: `jwt/api_jwt.py`, `PyJWT._merge_options()` (called from `decode_complete()`, which backs both `jwt.decode()` and `jwt.decode_complete()`):\n\n```python\ndef _merge_options(self, options=None):\n if options is None:\n return self.options\n if not options.get(\"verify_signature\", True):\n options[\"verify_exp\"] = options.get(\"verify_exp\", False)\n options[\"verify_nbf\"] = options.get(\"verify_nbf\", False)\n # ...5 more, same pattern -- all written directly onto the caller\u0027s dict\n return {**self.options, **options}\n```\n\nNo `dict(options)` copy occurs before these writes, unlike the pre-2025-03-05 code (see #743\u0027s fix, `options = dict(options or {})`). Confirmed via `gh api repos/jpadilla/pyjwt/commits/29fbfc36` that #1045\u0027s diff removes that line. Confirmed still present on current `main` via a live fetch of `jwt/api_jwt.py`.\n\n### PoC\n\nTwo scripts, each run twice (separate processes), identical output both times (except Python object `id()`), saved locally and available on request:\n\n```python\nimport jwt, time\n\nINSECURE_OPTIONS = {\"verify_signature\": False}\n\nsecret = \"s3cr3t\"\nexpired_token = jwt.encode(\n {\"exp\": int(time.time()) - 3600, \"data\": \"x\"}, secret, algorithm=\"HS256\"\n)\n\njwt.decode(expired_token, options=INSECURE_OPTIONS)\nprint(INSECURE_OPTIONS)\n# The dict has been silently mutated with 7 new keys, including verify_exp: False.\n\nINSECURE_OPTIONS[\"verify_signature\"] = True\nresult = jwt.decode(expired_token, secret, algorithms=[\"HS256\"], options=INSECURE_OPTIONS)\nprint(result)\n# No exception raised. Expected: jwt.ExpiredSignatureError, since verify_signature=True\n# was explicitly requested on this call.\n```\n\nReproduced deterministically across 2 independent process runs.\n\n### Impact\n\nPreconditions: the calling application must reuse a mutable `options` dict object across two or more `decode()`/`decode_complete()` calls with different intent (e.g. one \"insecure peek\" call followed by a \"fully verified\" call on the same dict, config object, or instance attribute). This is a real, plausible pattern but not universal; it does not affect code that constructs a fresh `options` dict per call.\n\nWhen triggered: a signed-but-expired (or wrong-aud/iss/sub/jti) token is silently accepted as fully verified, with no warning or exception. This is an authentication/claims-verification bypass, not a signature-verification bypass.\n\nScope: regression present since PR #1045 (merged 2025-03-05), affecting every PyJWT release since then up to and including the current 2.13.0.\n\nSuggested fix: restore a shallow copy inside `_merge_options()` itself (rather than relying on a caller-side copy), so every caller of `_merge_options` is covered:\n\n```python\ndef _merge_options(self, options=None):\n if options is None:\n return self.options\n options = dict(options) # never mutate the caller\u0027s dict\n if not options.get(\"verify_signature\", True):\n options.setdefault(\"verify_exp\", False)\n options.setdefault(\"verify_nbf\", False)\n options.setdefault(\"verify_iat\", False)\n options.setdefault(\"verify_aud\", False)\n options.setdefault(\"verify_iss\", False)\n options.setdefault(\"verify_sub\", False)\n options.setdefault(\"verify_jti\", False)\n return {**self.options, **options}\n```\n\nFound via a property-based audit (copy/clone independence across widely-used Python libraries); both repro scripts were written and independently re-run before this report.\n\n## Maintainer update \u2014 2026-09-08\n\nWe reproduced the reported behavior on PyJWT 2.13.0 at source SHA `7144e4534c34810f4525dc4578a32addd8212cff`. A caller-supplied mutable `options` mapping was modified when `verify_signature` was false; reusing that mapping after re-enabling signature verification could leave registered-claim checks disabled. This is a conditional claim-verification bypass, not a signature-forgery issue.\n\nA narrow fix has been prepared: PyJWT now copies the options mapping before applying defaults. Regression coverage includes `decode()`, `decode_complete()`, constructor input preservation, mutable-mapping compatibility, and reuse of an options mapping across verification modes. The focused tests pass, the full suite passes with 369 tests and 4 intentional cryptography-environment skips, and lint/format/type checks pass.\n\nThe advisory remains in triage while the fix goes through release planning. The independently verified range in this investigation is 2.13.0; reports of earlier affected versions should include the exact version and reproduction details.",
"id": "BREW-semgrep-CVE-2026-103001",
"modified": "2026-10-01T09:45:40Z",
"published": "2026-10-01T09:45:40Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/jpadilla/pyjwt/security/advisories/GHSA-gvp8-978c-rx2q"
},
{
"type": "WEB",
"url": "https://github.com/jpadilla/pyjwt/commit/0c87c8c8b1a74cac99ad8115f3050efcb7fbed35"
},
{
"type": "PACKAGE",
"url": "https://github.com/jpadilla/pyjwt"
}
],
"schema_version": "1.7.3",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:H/A:N",
"type": "CVSS_V3"
}
],
"summary": "PyJWT.decode() reintroduces options-dict mutation, enabling silent claim-verification bypass on dict reuse",
"upstream": [
"GHSA-gvp8-978c-rx2q",
"CVE-2026-103001"
]
}
BREW-SIGSTORE-CVE-2026-103001 (GHSA-GVP8-978C-RX2Q)
Vulnerability from osv_homebrew – Published: 2026-10-01 09:47 – Updated: 2026-10-01 09:47 – Source websiteSummary
PyJWT.decode()/decode_complete() mutates a caller-supplied options dict in place whenever verify_signature is falsy, adding verify_exp/verify_nbf/verify_iat/verify_aud/verify_iss/verify_sub/verify_jti keys directly onto that object. If application code reuses the same options dict across calls (a config object, a module-level constant, a wrapper's self.options), a later call that explicitly sets verify_signature=True for a full, normal verification silently inherits the leftover verify_exp=False etc. from an earlier verify_signature=False call on the same object -- and accepts an expired, wrong-audience, wrong-issuer, or wrong-subject token as valid with no exception. Signature verification itself is unaffected; this is specifically a claims-check bypass.
This is a regression of an already-fixed, closed issue: originally reported in #679 (2021), fixed in #743 (2022, via a shallow copy of options), and silently reintroduced by #1045 (merged 2025-03-05, a typing/logic refactor that dropped the 2022 shallow-copy line). It has been present since #1045 merged, so it likely shipped in several releases already in the wild.
Details
File: jwt/api_jwt.py, PyJWT._merge_options() (called from decode_complete(), which backs both jwt.decode() and jwt.decode_complete()):
def _merge_options(self, options=None):
if options is None:
return self.options
if not options.get("verify_signature", True):
options["verify_exp"] = options.get("verify_exp", False)
options["verify_nbf"] = options.get("verify_nbf", False)
# ...5 more, same pattern -- all written directly onto the caller's dict
return {**self.options, **options}
No dict(options) copy occurs before these writes, unlike the pre-2025-03-05 code (see #743's fix, options = dict(options or {})). Confirmed via gh api repos/jpadilla/pyjwt/commits/29fbfc36 that #1045's diff removes that line. Confirmed still present on current main via a live fetch of jwt/api_jwt.py.
PoC
Two scripts, each run twice (separate processes), identical output both times (except Python object id()), saved locally and available on request:
import jwt, time
INSECURE_OPTIONS = {"verify_signature": False}
secret = "s3cr3t"
expired_token = jwt.encode(
{"exp": int(time.time()) - 3600, "data": "x"}, secret, algorithm="HS256"
)
jwt.decode(expired_token, options=INSECURE_OPTIONS)
print(INSECURE_OPTIONS)
# The dict has been silently mutated with 7 new keys, including verify_exp: False.
INSECURE_OPTIONS["verify_signature"] = True
result = jwt.decode(expired_token, secret, algorithms=["HS256"], options=INSECURE_OPTIONS)
print(result)
# No exception raised. Expected: jwt.ExpiredSignatureError, since verify_signature=True
# was explicitly requested on this call.
Reproduced deterministically across 2 independent process runs.
Impact
Preconditions: the calling application must reuse a mutable options dict object across two or more decode()/decode_complete() calls with different intent (e.g. one "insecure peek" call followed by a "fully verified" call on the same dict, config object, or instance attribute). This is a real, plausible pattern but not universal; it does not affect code that constructs a fresh options dict per call.
When triggered: a signed-but-expired (or wrong-aud/iss/sub/jti) token is silently accepted as fully verified, with no warning or exception. This is an authentication/claims-verification bypass, not a signature-verification bypass.
Scope: regression present since PR #1045 (merged 2025-03-05), affecting every PyJWT release since then up to and including the current 2.13.0.
Suggested fix: restore a shallow copy inside _merge_options() itself (rather than relying on a caller-side copy), so every caller of _merge_options is covered:
def _merge_options(self, options=None):
if options is None:
return self.options
options = dict(options) # never mutate the caller's dict
if not options.get("verify_signature", True):
options.setdefault("verify_exp", False)
options.setdefault("verify_nbf", False)
options.setdefault("verify_iat", False)
options.setdefault("verify_aud", False)
options.setdefault("verify_iss", False)
options.setdefault("verify_sub", False)
options.setdefault("verify_jti", False)
return {**self.options, **options}
Found via a property-based audit (copy/clone independence across widely-used Python libraries); both repro scripts were written and independently re-run before this report.
Maintainer update — 2026-09-08
We reproduced the reported behavior on PyJWT 2.13.0 at source SHA 7144e4534c34810f4525dc4578a32addd8212cff. A caller-supplied mutable options mapping was modified when verify_signature was false; reusing that mapping after re-enabling signature verification could leave registered-claim checks disabled. This is a conditional claim-verification bypass, not a signature-forgery issue.
A narrow fix has been prepared: PyJWT now copies the options mapping before applying defaults. Regression coverage includes decode(), decode_complete(), constructor input preservation, mutable-mapping compatibility, and reuse of an options mapping across verification modes. The focused tests pass, the full suite passes with 369 tests and 4 intentional cryptography-environment skips, and lint/format/type checks pass.
The advisory remains in triage while the fix goes through release planning. The independently verified range in this investigation is 2.13.0; reports of earlier affected versions should include the exact version and reproduction details.
{
"affected": [
{
"ecosystem_specific": {
"fix": "bump",
"range_state": "fixed",
"resource": "pyjwt",
"resource_purl": "pkg:pypi/pyjwt@2.15.1"
},
"package": {
"ecosystem": "Homebrew",
"name": "sigstore",
"purl": "pkg:brew/sigstore"
},
"ranges": [
{
"events": [
{
"introduced": "4.2.0_1"
},
{
"fixed": "4.5.0_1"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"database_specific": {
"confidence": "high",
"source": "matched",
"strategy": "registry",
"upstream_evidence": [
{
"ecosystem": "PyPI",
"key": "pkg:pypi/pyjwt@2.15.1",
"name": "pyjwt",
"resource": "pyjwt",
"strategy": "registry",
"subject_version": "2.15.1"
}
]
},
"details": "### Summary\n\n`PyJWT.decode()`/`decode_complete()` mutates a caller-supplied `options` dict in place whenever `verify_signature` is falsy, adding `verify_exp`/`verify_nbf`/`verify_iat`/`verify_aud`/`verify_iss`/`verify_sub`/`verify_jti` keys directly onto that object. If application code reuses the same `options` dict across calls (a config object, a module-level constant, a wrapper\u0027s `self.options`), a later call that explicitly sets `verify_signature=True` for a full, normal verification silently inherits the leftover `verify_exp=False` etc. from an earlier `verify_signature=False` call on the same object -- and accepts an expired, wrong-audience, wrong-issuer, or wrong-subject token as valid with no exception. Signature verification itself is unaffected; this is specifically a claims-check bypass.\n\nThis is a regression of an already-fixed, closed issue: originally reported in #679 (2021), fixed in #743 (2022, via a shallow copy of `options`), and silently reintroduced by #1045 (merged 2025-03-05, a typing/logic refactor that dropped the 2022 shallow-copy line). It has been present since #1045 merged, so it likely shipped in several releases already in the wild.\n\n### Details\n\nFile: `jwt/api_jwt.py`, `PyJWT._merge_options()` (called from `decode_complete()`, which backs both `jwt.decode()` and `jwt.decode_complete()`):\n\n```python\ndef _merge_options(self, options=None):\n if options is None:\n return self.options\n if not options.get(\"verify_signature\", True):\n options[\"verify_exp\"] = options.get(\"verify_exp\", False)\n options[\"verify_nbf\"] = options.get(\"verify_nbf\", False)\n # ...5 more, same pattern -- all written directly onto the caller\u0027s dict\n return {**self.options, **options}\n```\n\nNo `dict(options)` copy occurs before these writes, unlike the pre-2025-03-05 code (see #743\u0027s fix, `options = dict(options or {})`). Confirmed via `gh api repos/jpadilla/pyjwt/commits/29fbfc36` that #1045\u0027s diff removes that line. Confirmed still present on current `main` via a live fetch of `jwt/api_jwt.py`.\n\n### PoC\n\nTwo scripts, each run twice (separate processes), identical output both times (except Python object `id()`), saved locally and available on request:\n\n```python\nimport jwt, time\n\nINSECURE_OPTIONS = {\"verify_signature\": False}\n\nsecret = \"s3cr3t\"\nexpired_token = jwt.encode(\n {\"exp\": int(time.time()) - 3600, \"data\": \"x\"}, secret, algorithm=\"HS256\"\n)\n\njwt.decode(expired_token, options=INSECURE_OPTIONS)\nprint(INSECURE_OPTIONS)\n# The dict has been silently mutated with 7 new keys, including verify_exp: False.\n\nINSECURE_OPTIONS[\"verify_signature\"] = True\nresult = jwt.decode(expired_token, secret, algorithms=[\"HS256\"], options=INSECURE_OPTIONS)\nprint(result)\n# No exception raised. Expected: jwt.ExpiredSignatureError, since verify_signature=True\n# was explicitly requested on this call.\n```\n\nReproduced deterministically across 2 independent process runs.\n\n### Impact\n\nPreconditions: the calling application must reuse a mutable `options` dict object across two or more `decode()`/`decode_complete()` calls with different intent (e.g. one \"insecure peek\" call followed by a \"fully verified\" call on the same dict, config object, or instance attribute). This is a real, plausible pattern but not universal; it does not affect code that constructs a fresh `options` dict per call.\n\nWhen triggered: a signed-but-expired (or wrong-aud/iss/sub/jti) token is silently accepted as fully verified, with no warning or exception. This is an authentication/claims-verification bypass, not a signature-verification bypass.\n\nScope: regression present since PR #1045 (merged 2025-03-05), affecting every PyJWT release since then up to and including the current 2.13.0.\n\nSuggested fix: restore a shallow copy inside `_merge_options()` itself (rather than relying on a caller-side copy), so every caller of `_merge_options` is covered:\n\n```python\ndef _merge_options(self, options=None):\n if options is None:\n return self.options\n options = dict(options) # never mutate the caller\u0027s dict\n if not options.get(\"verify_signature\", True):\n options.setdefault(\"verify_exp\", False)\n options.setdefault(\"verify_nbf\", False)\n options.setdefault(\"verify_iat\", False)\n options.setdefault(\"verify_aud\", False)\n options.setdefault(\"verify_iss\", False)\n options.setdefault(\"verify_sub\", False)\n options.setdefault(\"verify_jti\", False)\n return {**self.options, **options}\n```\n\nFound via a property-based audit (copy/clone independence across widely-used Python libraries); both repro scripts were written and independently re-run before this report.\n\n## Maintainer update \u2014 2026-09-08\n\nWe reproduced the reported behavior on PyJWT 2.13.0 at source SHA `7144e4534c34810f4525dc4578a32addd8212cff`. A caller-supplied mutable `options` mapping was modified when `verify_signature` was false; reusing that mapping after re-enabling signature verification could leave registered-claim checks disabled. This is a conditional claim-verification bypass, not a signature-forgery issue.\n\nA narrow fix has been prepared: PyJWT now copies the options mapping before applying defaults. Regression coverage includes `decode()`, `decode_complete()`, constructor input preservation, mutable-mapping compatibility, and reuse of an options mapping across verification modes. The focused tests pass, the full suite passes with 369 tests and 4 intentional cryptography-environment skips, and lint/format/type checks pass.\n\nThe advisory remains in triage while the fix goes through release planning. The independently verified range in this investigation is 2.13.0; reports of earlier affected versions should include the exact version and reproduction details.",
"id": "BREW-sigstore-CVE-2026-103001",
"modified": "2026-10-01T09:47:39Z",
"published": "2026-10-01T09:47:39Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/jpadilla/pyjwt/security/advisories/GHSA-gvp8-978c-rx2q"
},
{
"type": "WEB",
"url": "https://github.com/jpadilla/pyjwt/commit/0c87c8c8b1a74cac99ad8115f3050efcb7fbed35"
},
{
"type": "PACKAGE",
"url": "https://github.com/jpadilla/pyjwt"
}
],
"schema_version": "1.7.3",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:H/A:N",
"type": "CVSS_V3"
}
],
"summary": "PyJWT.decode() reintroduces options-dict mutation, enabling silent claim-verification bypass on dict reuse",
"upstream": [
"GHSA-gvp8-978c-rx2q",
"CVE-2026-103001"
]
}
BREW-SNOWFLAKE-CLI-CVE-2… (GHSA-GVP8-978C-RX2Q)
Vulnerability from osv_homebrew – Published: 2026-10-01 10:11 – Updated: 2026-10-01 10:11 – Source websiteSummary
PyJWT.decode()/decode_complete() mutates a caller-supplied options dict in place whenever verify_signature is falsy, adding verify_exp/verify_nbf/verify_iat/verify_aud/verify_iss/verify_sub/verify_jti keys directly onto that object. If application code reuses the same options dict across calls (a config object, a module-level constant, a wrapper's self.options), a later call that explicitly sets verify_signature=True for a full, normal verification silently inherits the leftover verify_exp=False etc. from an earlier verify_signature=False call on the same object -- and accepts an expired, wrong-audience, wrong-issuer, or wrong-subject token as valid with no exception. Signature verification itself is unaffected; this is specifically a claims-check bypass.
This is a regression of an already-fixed, closed issue: originally reported in #679 (2021), fixed in #743 (2022, via a shallow copy of options), and silently reintroduced by #1045 (merged 2025-03-05, a typing/logic refactor that dropped the 2022 shallow-copy line). It has been present since #1045 merged, so it likely shipped in several releases already in the wild.
Details
File: jwt/api_jwt.py, PyJWT._merge_options() (called from decode_complete(), which backs both jwt.decode() and jwt.decode_complete()):
def _merge_options(self, options=None):
if options is None:
return self.options
if not options.get("verify_signature", True):
options["verify_exp"] = options.get("verify_exp", False)
options["verify_nbf"] = options.get("verify_nbf", False)
# ...5 more, same pattern -- all written directly onto the caller's dict
return {**self.options, **options}
No dict(options) copy occurs before these writes, unlike the pre-2025-03-05 code (see #743's fix, options = dict(options or {})). Confirmed via gh api repos/jpadilla/pyjwt/commits/29fbfc36 that #1045's diff removes that line. Confirmed still present on current main via a live fetch of jwt/api_jwt.py.
PoC
Two scripts, each run twice (separate processes), identical output both times (except Python object id()), saved locally and available on request:
import jwt, time
INSECURE_OPTIONS = {"verify_signature": False}
secret = "s3cr3t"
expired_token = jwt.encode(
{"exp": int(time.time()) - 3600, "data": "x"}, secret, algorithm="HS256"
)
jwt.decode(expired_token, options=INSECURE_OPTIONS)
print(INSECURE_OPTIONS)
# The dict has been silently mutated with 7 new keys, including verify_exp: False.
INSECURE_OPTIONS["verify_signature"] = True
result = jwt.decode(expired_token, secret, algorithms=["HS256"], options=INSECURE_OPTIONS)
print(result)
# No exception raised. Expected: jwt.ExpiredSignatureError, since verify_signature=True
# was explicitly requested on this call.
Reproduced deterministically across 2 independent process runs.
Impact
Preconditions: the calling application must reuse a mutable options dict object across two or more decode()/decode_complete() calls with different intent (e.g. one "insecure peek" call followed by a "fully verified" call on the same dict, config object, or instance attribute). This is a real, plausible pattern but not universal; it does not affect code that constructs a fresh options dict per call.
When triggered: a signed-but-expired (or wrong-aud/iss/sub/jti) token is silently accepted as fully verified, with no warning or exception. This is an authentication/claims-verification bypass, not a signature-verification bypass.
Scope: regression present since PR #1045 (merged 2025-03-05), affecting every PyJWT release since then up to and including the current 2.13.0.
Suggested fix: restore a shallow copy inside _merge_options() itself (rather than relying on a caller-side copy), so every caller of _merge_options is covered:
def _merge_options(self, options=None):
if options is None:
return self.options
options = dict(options) # never mutate the caller's dict
if not options.get("verify_signature", True):
options.setdefault("verify_exp", False)
options.setdefault("verify_nbf", False)
options.setdefault("verify_iat", False)
options.setdefault("verify_aud", False)
options.setdefault("verify_iss", False)
options.setdefault("verify_sub", False)
options.setdefault("verify_jti", False)
return {**self.options, **options}
Found via a property-based audit (copy/clone independence across widely-used Python libraries); both repro scripts were written and independently re-run before this report.
Maintainer update — 2026-09-08
We reproduced the reported behavior on PyJWT 2.13.0 at source SHA 7144e4534c34810f4525dc4578a32addd8212cff. A caller-supplied mutable options mapping was modified when verify_signature was false; reusing that mapping after re-enabling signature verification could leave registered-claim checks disabled. This is a conditional claim-verification bypass, not a signature-forgery issue.
A narrow fix has been prepared: PyJWT now copies the options mapping before applying defaults. Regression coverage includes decode(), decode_complete(), constructor input preservation, mutable-mapping compatibility, and reuse of an options mapping across verification modes. The focused tests pass, the full suite passes with 369 tests and 4 intentional cryptography-environment skips, and lint/format/type checks pass.
The advisory remains in triage while the fix goes through release planning. The independently verified range in this investigation is 2.13.0; reports of earlier affected versions should include the exact version and reproduction details.
{
"affected": [
{
"ecosystem_specific": {
"fix": "bump",
"range_state": "fixed",
"resource": "pyjwt",
"resource_purl": "pkg:pypi/pyjwt@2.15.0"
},
"package": {
"ecosystem": "Homebrew",
"name": "snowflake-cli",
"purl": "pkg:brew/snowflake-cli"
},
"ranges": [
{
"events": [
{
"introduced": "3.15.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": "### Summary\n\n`PyJWT.decode()`/`decode_complete()` mutates a caller-supplied `options` dict in place whenever `verify_signature` is falsy, adding `verify_exp`/`verify_nbf`/`verify_iat`/`verify_aud`/`verify_iss`/`verify_sub`/`verify_jti` keys directly onto that object. If application code reuses the same `options` dict across calls (a config object, a module-level constant, a wrapper\u0027s `self.options`), a later call that explicitly sets `verify_signature=True` for a full, normal verification silently inherits the leftover `verify_exp=False` etc. from an earlier `verify_signature=False` call on the same object -- and accepts an expired, wrong-audience, wrong-issuer, or wrong-subject token as valid with no exception. Signature verification itself is unaffected; this is specifically a claims-check bypass.\n\nThis is a regression of an already-fixed, closed issue: originally reported in #679 (2021), fixed in #743 (2022, via a shallow copy of `options`), and silently reintroduced by #1045 (merged 2025-03-05, a typing/logic refactor that dropped the 2022 shallow-copy line). It has been present since #1045 merged, so it likely shipped in several releases already in the wild.\n\n### Details\n\nFile: `jwt/api_jwt.py`, `PyJWT._merge_options()` (called from `decode_complete()`, which backs both `jwt.decode()` and `jwt.decode_complete()`):\n\n```python\ndef _merge_options(self, options=None):\n if options is None:\n return self.options\n if not options.get(\"verify_signature\", True):\n options[\"verify_exp\"] = options.get(\"verify_exp\", False)\n options[\"verify_nbf\"] = options.get(\"verify_nbf\", False)\n # ...5 more, same pattern -- all written directly onto the caller\u0027s dict\n return {**self.options, **options}\n```\n\nNo `dict(options)` copy occurs before these writes, unlike the pre-2025-03-05 code (see #743\u0027s fix, `options = dict(options or {})`). Confirmed via `gh api repos/jpadilla/pyjwt/commits/29fbfc36` that #1045\u0027s diff removes that line. Confirmed still present on current `main` via a live fetch of `jwt/api_jwt.py`.\n\n### PoC\n\nTwo scripts, each run twice (separate processes), identical output both times (except Python object `id()`), saved locally and available on request:\n\n```python\nimport jwt, time\n\nINSECURE_OPTIONS = {\"verify_signature\": False}\n\nsecret = \"s3cr3t\"\nexpired_token = jwt.encode(\n {\"exp\": int(time.time()) - 3600, \"data\": \"x\"}, secret, algorithm=\"HS256\"\n)\n\njwt.decode(expired_token, options=INSECURE_OPTIONS)\nprint(INSECURE_OPTIONS)\n# The dict has been silently mutated with 7 new keys, including verify_exp: False.\n\nINSECURE_OPTIONS[\"verify_signature\"] = True\nresult = jwt.decode(expired_token, secret, algorithms=[\"HS256\"], options=INSECURE_OPTIONS)\nprint(result)\n# No exception raised. Expected: jwt.ExpiredSignatureError, since verify_signature=True\n# was explicitly requested on this call.\n```\n\nReproduced deterministically across 2 independent process runs.\n\n### Impact\n\nPreconditions: the calling application must reuse a mutable `options` dict object across two or more `decode()`/`decode_complete()` calls with different intent (e.g. one \"insecure peek\" call followed by a \"fully verified\" call on the same dict, config object, or instance attribute). This is a real, plausible pattern but not universal; it does not affect code that constructs a fresh `options` dict per call.\n\nWhen triggered: a signed-but-expired (or wrong-aud/iss/sub/jti) token is silently accepted as fully verified, with no warning or exception. This is an authentication/claims-verification bypass, not a signature-verification bypass.\n\nScope: regression present since PR #1045 (merged 2025-03-05), affecting every PyJWT release since then up to and including the current 2.13.0.\n\nSuggested fix: restore a shallow copy inside `_merge_options()` itself (rather than relying on a caller-side copy), so every caller of `_merge_options` is covered:\n\n```python\ndef _merge_options(self, options=None):\n if options is None:\n return self.options\n options = dict(options) # never mutate the caller\u0027s dict\n if not options.get(\"verify_signature\", True):\n options.setdefault(\"verify_exp\", False)\n options.setdefault(\"verify_nbf\", False)\n options.setdefault(\"verify_iat\", False)\n options.setdefault(\"verify_aud\", False)\n options.setdefault(\"verify_iss\", False)\n options.setdefault(\"verify_sub\", False)\n options.setdefault(\"verify_jti\", False)\n return {**self.options, **options}\n```\n\nFound via a property-based audit (copy/clone independence across widely-used Python libraries); both repro scripts were written and independently re-run before this report.\n\n## Maintainer update \u2014 2026-09-08\n\nWe reproduced the reported behavior on PyJWT 2.13.0 at source SHA `7144e4534c34810f4525dc4578a32addd8212cff`. A caller-supplied mutable `options` mapping was modified when `verify_signature` was false; reusing that mapping after re-enabling signature verification could leave registered-claim checks disabled. This is a conditional claim-verification bypass, not a signature-forgery issue.\n\nA narrow fix has been prepared: PyJWT now copies the options mapping before applying defaults. Regression coverage includes `decode()`, `decode_complete()`, constructor input preservation, mutable-mapping compatibility, and reuse of an options mapping across verification modes. The focused tests pass, the full suite passes with 369 tests and 4 intentional cryptography-environment skips, and lint/format/type checks pass.\n\nThe advisory remains in triage while the fix goes through release planning. The independently verified range in this investigation is 2.13.0; reports of earlier affected versions should include the exact version and reproduction details.",
"id": "BREW-snowflake-cli-CVE-2026-103001",
"modified": "2026-10-01T10:11:41Z",
"published": "2026-10-01T10:11:41Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/jpadilla/pyjwt/security/advisories/GHSA-gvp8-978c-rx2q"
},
{
"type": "WEB",
"url": "https://github.com/jpadilla/pyjwt/commit/0c87c8c8b1a74cac99ad8115f3050efcb7fbed35"
},
{
"type": "PACKAGE",
"url": "https://github.com/jpadilla/pyjwt"
}
],
"schema_version": "1.7.3",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:H/A:N",
"type": "CVSS_V3"
}
],
"summary": "PyJWT.decode() reintroduces options-dict mutation, enabling silent claim-verification bypass on dict reuse",
"upstream": [
"GHSA-gvp8-978c-rx2q",
"CVE-2026-103001"
]
}
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.