PT-2026-105892 · Pypi · Pyjwt
Publicado
2026-10-01
·
Atualizado
2026-10-01
CVSS v3.1
6.5
Média
| Vetor | AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:H/A:N |
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.
Correção
Encontrou algum problema na descrição? Tem algo a acrescentar? Fique à vontade para nos escrever 👾
Identificadores relacionados
Produtos afetados
Pyjwt