DEV Community

jeffrey
jeffrey

Posted on

A Passed options Dict: How PyJWT's decode() Silently Rewrote a Caller's Configuration

A Passed options Dict: How PyJWT's decode() Silently Rewrote a Caller's Configuration

Library bug reports usually describe a boundary that failed. This one describes a regression: a defect that was reported in 2021, fixed in 2022, and quietly reintroduced by an unrelated refactor in 2025. The result is a claims-check bypass that depends on how the calling application passes a configuration object.

The mechanism

PyJWT.decode() and decode_complete() accept a caller-supplied options dictionary. PyJWT._merge_options() in jwt/api_jwt.py handles it as follows:

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}
Enter fullscreen mode Exit fullscreen mode

When verify_signature is falsy, seven verify_* keys are written directly onto the object the caller passed. No dict(options) copy occurs before those writes.
If application code reuses that same dictionary across calls, the writes persist. A later call that sets verify_signature=True for a full verification is merged with the leftover verify_exp=False and its siblings, and a signed but expired, wrong-audience, wrong-issuer or wrong-subject token is accepted with no exception. Signature verification itself is unaffected: this is specifically a claims check bypass.

Why it is a regression rather than a new finding

The advisory traces the history. The issue was first reported in issue 679 in 2021 and fixed in 2022 by a shallow copy of options. It was silently reintroduced by PR 1045, merged on 5 March 2025, a typing and logic refactor that dropped the copy line. The advisory confirms the removal by inspecting the commit diff, and confirms the code is still present on the current main branch. Because the regression has been in place since March 2025, it has likely shipped in several releases.

What the proof does

The reproduction is deliberately small. Two scripts, each run twice in separate processes with identical output:

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)
INSECURE_OPTIONS["verify_signature"] = True
result = jwt.decode(expired_token, secret, algorithms=["HS256"], options=INSECURE_OPTIONS)
print(result)
Enter fullscreen mode Exit fullscreen mode

The first call mutates the shared dictionary with seven new keys, including verify_exp: False. The second call raises no exception, where jwt.ExpiredSignatureError would be expected.

The precondition is the whole story

The advisory is careful about scope, and the care is justified. The bypass requires the calling application to reuse one mutable options object across two or more calls with different intent: an insecure peek followed by a fully verified decode on the same dictionary, config object, or instance attribute. Code that constructs a fresh options dict per call is not affected.
That pattern is plausible rather than universal, and the distinction matters for triage. The affected range is 2.11.0 through 2.13.0, and the advisory records no patched version at publication, so a workaround is required in the short term.

What to do

The direct workaround is to stop sharing the object. Pass a fresh dictionary on every call, or copy before passing:

jwt.decode(token, secret, algorithms=["HS256"], options=dict(VERIFY_OPTIONS))
Enter fullscreen mode Exit fullscreen mode

An audit is worth running alongside the change. Search for module-level or instance-level options dictionaries passed to decode() or decode_complete(), and for any call site that sets verify_signature to a falsy value. Those are the two conditions that have to meet.
Where a dependency bump is the preferred route, tracking the upstream issue is better than relying on a version number, since the regression spent more than a year in shipped releases without being noticed.

A note on the reporting

The advisory is a useful example of regression analysis in a security record. It gives the original issue number, the commit that fixed it, the pull request that undid the fix, and a live check of the current branch, which is more than most reports of this class provide and considerably more than a version range alone.

References

Top comments (0)