CVE-2026-103001: PyJWT.decode() reintroduces options-dict mutation, enabling silent claim-verification bypass on dict reuse
Summary
PyJWT.decode()/decodecomplete() mutates a caller-supplied options dict in place whenever verifysignature is falsy, adding verifyexp/verifynbf/verifyiat/verifyaud/verifyiss/verifysub/verifyjti 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 verifysignature=True for a full, normal verification silently inherits the leftover verifyexp=False etc. from an earlier verifysignature=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/apijwt.py, PyJWT.mergeoptions() (called from decodecomplete(), which backs both jwt.decode() and jwt.decodecomplete()):
python def mergeoptions(self, options=None): if options is None: return self.options if not options.get("verifysignature", True): options["verifyexp"] = options.get("verifyexp", False) options["verifynbf"] = options.get("verifynbf", 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/apijwt.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
INSECUREOPTIONS = {"verifysignature": False}
secret = "s3cr3t" expiredtoken = jwt.encode( {"exp": int(time.time()) - 3600, "data": "x"}, secret, algorithm="HS256" )
jwt.decode(expiredtoken, options=INSECUREOPTIONS) print(INSECUREOPTIONS) The dict has been silently mutated with 7 new keys, including verifyexp: False.
INSECUREOPTIONS["verifysignature"] = True result = jwt.decode(expiredtoken, secret, algorithms=["HS256"], options=INSECUREOPTIONS) print(result) No exception raised. Expected: jwt.ExpiredSignatureError, since verifysignature=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()/decodecomplete() 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 mergeoptions() itself (rather than relying on a caller-side copy), so every caller of mergeoptions is covered:
python def mergeoptions(self, options=None): if options is None: return self.options options = dict(options) # never mutate the caller's dict if not options.get("verifysignature", True): options.setdefault("verifyexp", False) options.setdefault("verifynbf", False) options.setdefault("verifyiat", False) options.setdefault("verifyaud", False) options.setdefault("verifyiss", False) options.setdefault("verifysub", False) options.setdefault("verifyjti", 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 verifysignature 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(), decodecomplete(), 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.
Other sources
PyJWT is a Python implementation of JSON Web Token standards. From 2.11.0 through 2.13.0, PyJWT's PyJWT.mergeoptions() method can modify a caller-supplied mutable options mapping when verifysignature is false. If an application reuses that same mapping for a later decode() or decodecomplete() call and changes verifysignature 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.
— MITRE
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Compensating control
In PyJWT's `PyJWT._merge_options()`, make a shallow copy of the caller-supplied mapping before applying defaults, using `options = dict(options)` so the caller's `options` dict is not mutated.
Event History
Frequently Asked Questions
Which applications are exposed to this issue?
Applications using PyJWT versions 2.11.0 through 2.13.0 are exposed only if they reuse a mutable options mapping after decoding with verify_signature set to false. Applications that create a fresh options mapping for every decode() or decode_complete() call are not affected.
What must occur for invalid claims to be accepted?
A caller-supplied options mapping must first be used with verify_signature set to false, then reused for a later decode() or decode_complete() call with verify_signature changed to true. The later token must have a valid signature but invalid registered claims, such as expiration, audience, issuer, subject, or JWT ID.
What can be done if updating PyJWT is not immediately possible?
Do not reuse mutable options mappings across token decoding calls. Create a new options mapping for each decode() or decode_complete() invocation, particularly when any call disables signature verification.
How can I determine whether my application is affected?
Review code paths that call decode() or decode_complete() for shared or reused options dictionaries. The application is affected if the same mutable mapping is used first with verify_signature false and later with verification re-enabled.