Skip to content

Scan JWS segments for non-Base64URL bytes without a per-byte loop - #1223

Open
Dilshad-Tanvi wants to merge 1 commit into
jpadilla:masterfrom
Dilshad-Tanvi:base64url-alphabet-scan
Open

Dilshad-Tanvi wants to merge 1 commit into
jpadilla:masterfrom
Dilshad-Tanvi:base64url-alphabet-scan

Conversation

@Dilshad-Tanvi

Copy link
Copy Markdown

The Base64URL alphabet check in _decode_base64url_segment is a generator over every byte of the segment, so CPython builds an int object per byte. _load calls it on the header, the payload and the signature before _verify_signature runs, which means an unauthenticated caller pays for it whether or not the signature is any good. Benchmarking a decode of a 1 MiB token on my machine, that scan alone measures around 50 ms per mebibyte, and wanders well above that under allocator pressure, against roughly 12 ms for the HMAC it is gating.

bytes.translate deletes exactly the alphabet bytes in one C-level pass, so a non-empty remainder means the same thing the generator meant, and the scan drops to about 1 ms per mebibyte. I kept the check where it is rather than leaning on the canonical re-encode a few lines below, since that keeps the junk rejection from GHSA-hxm8-2xgr-2p9m explicit at the point where it is decided. I checked the two forms agree over about 990k segments, including every one of the 256 byte values in several positions, and added a test that walks every byte outside the alphabet.

@Dilshad-Tanvi
Dilshad-Tanvi force-pushed the base64url-alphabet-scan branch from 3d29f33 to 00f8944 Compare October 22, 2026 10:36
@Dilshad-Tanvi

Copy link
Copy Markdown
Author

Rebased onto master to clear the CHANGELOG conflict with #1211, both entries are kept under Changed.

@clemlesne

Copy link
Copy Markdown

Supporting numbers at an ordinary token size, in case they help the review: far below the 1 MiB case in the description, the scan is still about half of a full decode.

A 1,234-byte RS256 access token (2048-bit key, an OIDC-style access-token claim set), PyJWT 2.15.1, CPython 3.14.3 on an Apple M5 Pro, median of 20,000 calls (the middle of three runs):

get_unverified_header decode
2.15.1 27.6 µs 43.0 µs
2.15.1 with this PR's translate scan 5.2 µs 19.5 µs

An application that calls jwt.get_unverified_header to pick a key and then jwt.decode runs the scan over all three segments twice; the two calls take about 71 µs against 25 µs here. PyJWKClient.get_signing_key_from_jwt also parses every segment before decode does.

Reproduction

uv run --no-project --with 'pyjwt[crypto]==2.15.1' python repro.py

"""Time PyJWT's Base64URL alphabet scan on an ordinary RS256 access token, with and without #1223's change."""

import inspect
import json
import platform
import statistics
import time
import uuid

import jwt
from cryptography.hazmat.primitives.asymmetric import rsa
from jwt import PyJWK
from jwt.algorithms import RSAAlgorithm
from jwt.api_jws import PyJWS

key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
jwk = PyJWK(json.loads(RSAAlgorithm.to_jwk(key.public_key())) | {"kid": "k1", "alg": "RS256", "use": "sig"})
now = int(time.time())
claims = {
    "iss": "https://idp.example.com/realms/example",
    "sub": str(uuid.uuid4()),
    "aud": ["https://api.example.com", "account"],
    "exp": now + 300,
    "iat": now,
    "jti": str(uuid.uuid4()),
    "azp": "example-client",
    "scope": "openid profile email offline_access",
    "realm_access": {"roles": ["default-roles-example", "offline_access", "uma_authorization"]},
    "resource_access": {"account": {"roles": ["manage-account", "manage-account-links", "view-profile"]}},
    "email_verified": True,
    "name": "Example User",
    "preferred_username": "example.user",
    "given_name": "Example",
    "family_name": "User",
    "email": "example.user@example.com",
}
token = jwt.encode(claims, key, algorithm="RS256", headers={"kid": "k1"})


def decode() -> None:
    jwt.decode(token, jwk, algorithms=["RS256"], audience="https://api.example.com")


def p50_us(fn, n: int = 20_000) -> float:
    for _ in range(2_000):
        fn()
    samples = []
    for _ in range(n):
        start = time.perf_counter_ns()
        fn()
        samples.append(time.perf_counter_ns() - start)
    return statistics.median(samples) / 1_000


def patched_segment():
    source = inspect.getsource(PyJWS._decode_base64url_segment)
    old = "any(\n            character not in _BASE64URL_ALPHABET for character in stripped\n        )"
    assert source.count(old) == 1, "unexpected source; compare with the PR diff"
    source = source.replace(old, "stripped.translate(None, _BASE64URL_ALPHABET)")
    namespace = vars(__import__("jwt.api_jws", fromlist=["*"])).copy()
    exec(compile("\n".join(line[4:] for line in source.splitlines()), "patched", "exec"), namespace)
    return namespace["_decode_base64url_segment"]


print(f"PyJWT {jwt.__version__}, CPython {platform.python_version()} on {platform.machine()}, token {len(token)} bytes")
original = PyJWS._decode_base64url_segment
rows = []
for label in ("released", "with #1223's scan"):
    if label != "released":
        PyJWS._decode_base64url_segment = patched_segment()
    rows.append((label, p50_us(lambda: jwt.get_unverified_header(token)), p50_us(decode)))
PyJWS._decode_base64url_segment = original
print(f"{'':<20}{'get_unverified_header':>24}{'decode (RS256)':>18}")
for label, header, full in rows:
    print(f"{label:<20}{header:>21.1f} us{full:>15.1f} us")

@Dilshad-Tanvi

Copy link
Copy Markdown
Author

Those numbers line up with what I saw at the 1 KiB end, and the get_unverified_header plus decode pairing is a good point, since that pattern (and PyJWKClient.get_signing_key_from_jwt) runs the scan over all three segments twice before any signature is checked, so the cost shows up at ordinary token sizes too, not just the large-token case in the description.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants