CVE-2026-103001: PyJWT PyJWT.decode() Reintroduces Options-dict Mutation, Enabling Vulnerability

MODERATE
Advisory Identifier: GHSA-gvp8-978c-rx2q
CVSS 6.5
CWE-471
Published
Advisory Summary

CVE-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

CVE Identifier CVE-2026-103001
GHSA Identifier GHSA-gvp8-978c-rx2q
Severity MODERATE
CVSS v3.1 Base Score 6.5
CWE Classification CWE-471: Modification of Assumed-Immutable Data (MAID)
Affected Product PyJWT
Package Ecosystem PyPI
Published Date
Last Updated

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).

Attack Vector Network (AV:N)
Attack Complexity High (AC:H)
Privileges Required None (PR:N)
User Interaction None (UI:N)
Scope Unchanged (S:U)
Confidentiality Low (C:L)
Integrity High (I:H)
Availability None (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.

PyJWT fixed: Latest secure release
pip install PyJWT>=Latest secure release
Verify Direct and Transitive Dependencies

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

### Summary `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()`): ```python 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: ```python 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: ```python 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.

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?
CVE-2026-103001 is a security vulnerability in PyJWT involving PyJWT.decode() reintroduces options-dict mutation, enabling silent claim-verification bypass on dict reuse under CWE-471: Modification of Assumed-Immutable Data (MAID).
Which PyJWT versions are affected?
Affected versions include releases prior to current release patches across affected packages: PyJWT.
What version fixes CVE-2026-103001?
Upgrading to current release patches (or the latest patched release for your respective branch) resolves the vulnerability.
Is PyJWT affected?
Yes, PyJWT is affected in releases prior to Latest secure release. Upgrade your dependency to Latest secure release or later.
What is the CVSS score of CVE-2026-103001?
CVE-2026-103001 has a CVSS v3.1 base score of 6.5 (MODERATE) with vector string CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:H/A:N.
What CWE is associated with CVE-2026-103001?
CVE-2026-103001 is classified under CWE-471: Modification of Assumed-Immutable Data (MAID) (The product does not properly protect an assumed-immutable element from being modified by an attacker.).
How can I check PyPI dependencies for CVE-2026-103001?
Run the dependency tree command: 'pip show PyJWT'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "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
  }
}