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.