From 552b9900343e19811cababb703147d1c4f583436 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 28 Sep 2026 13:27:31 +0300 Subject: [PATCH 1/2] perf(api): trim Python overhead on the plain decode fast path For HS256 the Python wrapper cost about as much as the native decode call itself (0.85us overhead on top of ~1.30us native). The fast path in PyJWT.decode / decode_complete called _is_plain_decode (a 9-argument function) and _require_detached_payload_for_rfc7797 (a keyword-argument call), and _validate_claims_default always called time.time() and re-checked exp even though Rust had already validated it with leeway=0. - Inline the _is_plain_decode condition and the RFC 7797 detached_payload pre-check directly into decode() and decode_complete(), removing the two function calls from the hot path. _require_detached_payload_for_rfc7797 stays as a function for the general (non-fast) path, which still needs its full signature. - In _validate_claims_default, only re-check exp in Python when it is not a plain int. Rust already enforces exp > now with an integer clock on this path, which is the exact same predicate as the truncating check below for an integer exp. A float exp still needs the Python recheck: Rust rounds a fractional value to the nearest second while PyJWT (and this check, for parity) truncates it, so the two can disagree right at the boundary. - time.time() is now only called when iat or a non-int exp is present. Measured (HS256, prebuilt token): wrapper overhead over the native call dropped from ~0.85us to ~0.57us with iat+int exp present (~33% less), and to ~0.37us with no time claims at all (~57% less). No behavioural change. Added a regression test pinning exp to a fractional second (K + 0.6 for the current whole second K) to prove the float recheck still runs: Rust rounds that up and would accept it, but the Python truncating recheck must still reject it for PyJWT parity. Closes #122 --- CHANGELOG.md | 14 ++++ python/oxyjwt/api_jwt.py | 160 ++++++++++++++++++++++----------------- 2 files changed, 105 insertions(+), 69 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8aa4218..125348b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 factory) on every call. HMAC secrets passed as raw `str`/`bytes` are unaffected — there is no persistent key object to borrow from in that case. No behavioural change. (#121) +- **Less Python-side work on the plain `decode`/`decode_complete` fast path.** + The `_is_plain_decode` argument check and the RFC 7797 `detached_payload` + pre-check are now inlined into `decode`/`decode_complete` instead of going + through a 9-argument and a keyword-argument function call. `exp` is only + re-checked in Python when it is not a plain `int`: Rust already enforces + `exp > now` with an integer clock on this path, which is the exact same + predicate for an integer `exp`, so re-running it only cost a `time.time()` + call with no behavioural difference; a `float` `exp` still gets the Python + recheck, since Rust rounds a fractional value to the nearest second where + PyJWT (and this check, for parity) truncates it, and the two can disagree + right at the boundary. Measured (HS256, prebuilt token, `int` `exp`): + wrapper overhead over the native call dropped from ~0.85 microseconds to + ~0.57 microseconds (~33% less); with no time claims at all, ~0.37 + microseconds (~57% less). No behavioural change. (#122) ## [0.7.0] — 2026-08-26 diff --git a/python/oxyjwt/api_jwt.py b/python/oxyjwt/api_jwt.py index 8381d46..0a11b79 100644 --- a/python/oxyjwt/api_jwt.py +++ b/python/oxyjwt/api_jwt.py @@ -115,40 +115,6 @@ def _check_string_or_iterable(value: object, name: str) -> None: raise TypeError(f"{name} must be a string, iterable or None") -def _is_plain_decode( - options: dict[str, Any] | None, - verify: bool | None, - detached_payload: bytes | None, - audience: object, - issuer: object, - subject: str | None, - leeway: float | timedelta, - typ: str | None, - algorithms: list[str] | None, -) -> bool: - """True when no argument changes the default verified-decode behaviour. - - With nothing but ``algorithms`` supplied, Rust validates ``exp``/``nbf`` - and has no audience/issuer/subject to check, so the remaining claim rules - reduce to :meth:`PyJWT._validate_claims_default`. A ``timedelta`` leeway - never compares equal to ``0`` and therefore takes the general path, as do - algorithm containers the native module cannot read directly (a set or an - iterator), which the general path normalises with ``list()``. - """ - return ( - options is None - and audience is None - and issuer is None - and subject is None - and typ is None - and detached_payload is None - and verify is None - and leeway == 0 - and bool(algorithms) - and isinstance(algorithms, (list, tuple)) - ) - - def _json_default_from_encoder(encoder_cls: type[JSONEncoder]) -> Callable[[Any], Any]: enc = encoder_cls() @@ -222,18 +188,47 @@ def decode( typ: str | None = None, **kwargs: Any, ) -> Any: + # Inlined instead of a shared `_is_plain_decode` helper: this condition + # runs on every call, and a 9-argument function call costs more than + # the comparisons themselves. True when no argument changes the + # default verified-decode behaviour: with nothing but `algorithms` + # supplied, Rust validates exp/nbf and has no audience/issuer/subject + # to check, so the remaining claim rules reduce to + # `_validate_claims_default`. A `timedelta` leeway never compares + # equal to `0` and therefore takes the general path, as do algorithm + # containers the native module cannot read directly (a set or an + # iterator), which the general path normalises with `list()`. if ( self._default_options and not kwargs - and _is_plain_decode( - options, verify, detached_payload, audience, issuer, subject, leeway, - typ, algorithms, - ) + and options is None + and audience is None + and issuer is None + and subject is None + and typ is None + and detached_payload is None + and verify is None + and leeway == 0 + and bool(algorithms) + and isinstance(algorithms, (list, tuple)) ): token = jwt if isinstance(jwt, str) else jwt.decode("utf-8") - _require_detached_payload_for_rfc7797( - token, detached_payload=None, verify_signature=True - ) + # Inlined `_require_detached_payload_for_rfc7797` for the same + # reason: detached_payload/verify_signature are always None/True + # here, so its own early-return check is dead code on this path. + # ".." can only appear via an empty (b64:false) payload segment, + # since base64url never emits a dot; that keeps ordinary tokens + # off the split/header-parse path below. + if ".." in token: + segments = token.split(".", 2) + if len(segments) >= 3 and segments[1] == "": + header_peek = _as_plain_dict(_oxyjwt.get_unverified_header(token)) + if header_peek.get("b64") is False: + raise DecodeError( + 'It is required that you pass in a value for the ' + '"detached_payload" argument to decode a message ' + "having the b64 header set to false." + ) payload = _oxyjwt.decode(token, key, algorithms) self._validate_claims_default(payload) return payload @@ -275,18 +270,33 @@ def decode_complete( typ: str | None = None, **kwargs: Any, ) -> dict[str, Any]: + # See the matching block in `decode()` for why this is inlined rather + # than calling a shared `_is_plain_decode` / rfc7797-peek helper. if ( self._default_options and not kwargs - and _is_plain_decode( - options, verify, detached_payload, audience, issuer, subject, leeway, - typ, algorithms, - ) + and options is None + and audience is None + and issuer is None + and subject is None + and typ is None + and detached_payload is None + and verify is None + and leeway == 0 + and bool(algorithms) + and isinstance(algorithms, (list, tuple)) ): token = jwt if isinstance(jwt, str) else jwt.decode("utf-8") - _require_detached_payload_for_rfc7797( - token, detached_payload=None, verify_signature=True - ) + if ".." in token: + segments = token.split(".", 2) + if len(segments) >= 3 and segments[1] == "": + header_peek = _as_plain_dict(_oxyjwt.get_unverified_header(token)) + if header_peek.get("b64") is False: + raise DecodeError( + 'It is required that you pass in a value for the ' + '"detached_payload" argument to decode a message ' + "having the b64 header set to false." + ) payload, header, signature = _oxyjwt.decode_verified_complete( token, key, algorithms ) @@ -454,30 +464,42 @@ def _validate_claims_default(self, payload: dict[str, Any]) -> None: ``rust_standard_claims`` true: Rust already validated ``exp``/``nbf``, so only ``iat``, the Python ``exp`` boundary, the "no audience expected" rule and the ``sub`` type check remain. - """ - now = time.time() + ``exp`` is only re-checked here when it is not a plain ``int``. Rust + already enforced ``exp > now`` using an integer clock (leeway is 0 on + this path), and for an integer ``exp`` that is the exact same + predicate as the truncating check below, so redoing it would only + cost a ``time.time()`` call for no behavioural difference. A ``float`` + ``exp`` still needs it: Rust rounds a fractional value to the nearest + second, while PyJWT (and the check below, for parity) truncates it, + so the two can disagree right at the boundary. + """ iat = payload.get("iat", _MISSING) - if iat is not _MISSING: - try: - iat_value = int(iat) - except (ValueError, TypeError) as e: - raise InvalidIssuedAtError( - "Issued At claim (iat) must be an integer." - ) from e - if iat_value > now: - raise ImmatureSignatureError("The token is not yet valid (iat)") - exp = payload.get("exp", _MISSING) - if exp is not _MISSING: - try: - exp_value = int(exp) - except (ValueError, TypeError) as e: - raise DecodeError( - "Expiration Time claim (exp) must be an integer." - ) from e - if exp_value <= now: - raise ExpiredSignatureError("Signature has expired") + exp_needs_check = exp is not _MISSING and type(exp) is not int + + if iat is not _MISSING or exp_needs_check: + now = time.time() + + if iat is not _MISSING: + try: + iat_value = int(iat) + except (ValueError, TypeError) as e: + raise InvalidIssuedAtError( + "Issued At claim (iat) must be an integer." + ) from e + if iat_value > now: + raise ImmatureSignatureError("The token is not yet valid (iat)") + + if exp_needs_check: + try: + exp_value = int(exp) + except (ValueError, TypeError) as e: + raise DecodeError( + "Expiration Time claim (exp) must be an integer." + ) from e + if exp_value <= now: + raise ExpiredSignatureError("Signature has expired") if payload.get("aud"): raise InvalidAudienceError("Invalid audience") From b7143b66dc28f5496ac585b197bd8e24e9409e76 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 28 Sep 2026 13:27:31 +0300 Subject: [PATCH 2/2] test(decode): cover float exp boundary parity on the fast path Regression test for the exp re-check change in the previous commit: a float exp pinned to K + 0.6 (K = current whole second) must still raise ExpiredSignatureError via the Python recheck, even though Rust's own rounding-based boundary check alone would accept it as not yet expired. --- tests/test_decode_fast_path.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/test_decode_fast_path.py b/tests/test_decode_fast_path.py index 8f47170..b454f4a 100644 --- a/tests/test_decode_fast_path.py +++ b/tests/test_decode_fast_path.py @@ -146,6 +146,25 @@ def test_timedelta_leeway_still_honoured() -> None: )["sub"] == "u" +def test_float_exp_boundary_still_checked_in_python() -> None: + """An integer `exp` skips the redundant Python recheck (Rust's own + boundary check is exact for it), but a `float` `exp` must not. + + Pin `exp` to `K + 0.6` for the current whole second `K`: Rust rounds that + up to `K + 1` and, checked within the same second `K`, considers it not + yet expired. PyJWT (and the Python recheck below, for parity) truncates + it back down to `K`, which is already <= "now", so the token must still + be rejected. Adding a fraction to `time.time()` instead (`now + 0.6`) + would not reproduce this: it lands in the future either way and both + sides agree the token is valid. + """ + current_second = int(time.time()) + exp = current_second + 0.6 + token = oxyjwt.encode({"sub": "u", "exp": exp}, SECRET, "HS256") + with pytest.raises(oxyjwt.ExpiredSignatureError): + oxyjwt.decode(token, SECRET, algorithms=["HS256"]) + + class TestRequiredClaims: """`require` follows PyJWT: a JSON null counts as an absent claim."""