GHSA-GVP8-978C-RX2Q

Vulnerability from github – Published: 2026-09-30 23:53 – Updated: 2026-09-30 23:53
VLAI
Summary
PyJWT.decode() reintroduces options-dict mutation, enabling silent claim-verification bypass on dict reuse
Details

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()):

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.

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "PyPI",
        "name": "PyJWT"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "2.11.0"
            },
            {
              "last_affected": "2.13.0"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-103001"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-471"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-09-30T23:53:17Z",
    "nvd_published_at": null,
    "severity": "MODERATE"
  },
  "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": "GHSA-gvp8-978c-rx2q",
  "modified": "2026-09-30T23:53:17Z",
  "published": "2026-09-30T23:53:17Z",
  "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.4.0",
  "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"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

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.

Loading…

Loading…

Loading…

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.


Loading…