CVE-2026-103001: PyJWT PyJWT.decode() Reintroduces Options-dict Mutation, Enabling Vulnerability
MODERATECVE-2026-103001 is a MODERATE (CVSS 6.5) pyjwt.decode() reintroduces options-dict mutation, enabling vulnerability (CWE-471: Modification of Assumed-Immutable Data (MAID)) affecting PyJWT libraries in the PyPI ecosystem. The issue involves pyjwt.decode() reintroduces options-dict mutation, enabling silent claim-verification bypass on dict reuse. Remediation is available in the latest vendor maintenance releases.
CVE-2026-103001 at a Glance
Security Impact
CVSS: 6.5
The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:H/A:N).
How to Fix CVE-2026-103001
To remediate this vulnerability, upgrade all affected PyJWT dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.
pip install PyJWT>=Latest secure release
Run the following command in your project directory to verify that all resolved dependencies use the patched release:
pip show PyJWT
How to Check if You Are Affected
To determine whether your Python environment is affected by CVE-2026-103001, inspect your installed packages using pip:
pip show PyJWT
Analyze the terminal output to locate any version of PyJWT in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than the patched release), your application is affected.
Note: The pip show PyJWT command checks currently installed packages in your active Python environment.
Technical Details
Vulnerability Mechanism & Technical Analysis
This vulnerability is classified under CWE-471: Modification of Assumed-Immutable Data (MAID), where the product does not properly protect an assumed-immutable element from being modified by an attacker. In PyJWT, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.
Source Advisory Statement
CVE-2026-103001 Affected PyJWT Versions
1 component(s)| Product / Package | Ecosystem | Affected Versions | Fixed Version |
|---|---|---|---|
|
PyJWT
|
PyPI |
≥ 2.11.0
≤ 2.13.0
|
|
References
Frequently Asked Questions
What is CVE-2026-103001?
Which PyJWT versions are affected?
What version fixes CVE-2026-103001?
Is PyJWT affected?
What is the CVSS score of CVE-2026-103001?
What CWE is associated with CVE-2026-103001?
How can I check PyPI dependencies for CVE-2026-103001?
Machine-readable OSV JSON
OSV v1.5 Schema
{
"schema_version" : "1.4.0",
"id" : "GHSA-gvp8-978c-rx2q",
"modified" : "2026-09-30T23:53:17Z",
"published" : "2026-09-30T23:53:17Z",
"aliases" : [ "CVE-2026-103001" ],
"summary" : "PyJWT.decode() reintroduces options-dict mutation, enabling silent claim-verification bypass on dict reuse",
"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'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.\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's 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'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`.\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's 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 — 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.",
"severity" : [ {
"type" : "CVSS_V3",
"score" : "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:H/A:N"
} ],
"affected" : [ {
"package" : {
"ecosystem" : "PyPI",
"name" : "PyJWT"
},
"ranges" : [ {
"type" : "ECOSYSTEM",
"events" : [ {
"introduced" : "2.11.0"
}, {
"last_affected" : "2.13.0"
} ]
} ]
} ],
"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"
} ],
"database_specific" : {
"cwe_ids" : [ "CWE-471" ],
"severity" : "MODERATE",
"github_reviewed" : true,
"github_reviewed_at" : "2026-09-30T23:53:17Z",
"nvd_published_at" : null
}
}