Zion Boggan

In-depth vulnerability research, detection engineering & applied cryptography.

● Open to security-research & detection roles
GitHub · LinkedIn · Email
← All work
JWT / AUTH

Schism, JWT Differential Fuzzer

Differentially tests JWT libraries against each other and the RFCs to surface algorithm-confusion and parsing-divergence bypasses.

JWTDifferential fuzzingAlgorithm confusionRFC 7515RFC 7519Coordinated disclosureAuth bypass
5
JWT libraries differentially tested
73
cases in the seed corpus
13
bug classes covered
2
confirmed bypass advisories (F001, F002)
3
sister CVEs anchoring the disclosure

The differential approach

Schism does not decide on its own whether a token is valid. It treats the JWT ecosystem as its own oracle: it submits one corpus case to every running library and flags any case where the libraries do not agree. The orchestrator collapses the responses by the valid field; if the set of accepting verifiers and the set of rejecting verifiers are both non-empty, the case is flagged. An unanimous outcome, even an unanimously wrong one, is not a finding, because no library can be split against another.

Error wording is bucketed by class rather than compared literally, so the different diagnostic strings each library emits for the same rejection never produce a false positive. Only the boolean verdict matters. Where the corpus carries an expected_unanimous of reject and any library still accepts, the disagreement is labelled BYPASS; the inverse is DOS; anything else is SPLIT. The comparison and labelling logic, verbatim from orchestrator/differ.py:

def disagreement(results):
 verdicts = {n: r.get("valid") for n, r in results.items()}
 distinct = set(v for v in verdicts.values() if v is not None)
 return len(distinct) > 1

# ... per flagged case:
acceptors = [n for n, v in verdicts.items() if v is True]
rejectors = [n for n, v in verdicts.items() if v is False]

tag = "BYPASS" if (expected == "reject" and acceptors)\
 else "DOS" if (expected == "accept" and rejectors)\
 else "SPLIT"

Transport failures resolve to valid: None and are excluded from the distinct-verdict set, so an unreachable target degrades coverage rather than corrupting a comparison. The run exits non-zero when any case is flagged, which makes the harness usable as a regression gate.

Libraries under test

Five libraries run as v1 targets, each a thin HTTP wrapper over the library's own verify() call inside a minimal Docker container exposing POST /verify on a fixed port. The selection spans three languages and is weighted toward the libraries with the largest downstream blast radius and the longest history of JOSE-layer defects. jose (panva) is the most spec-compliant of the set and is used as the de facto compliance oracle.

IDLibraryLanguageRationale
nodejwtjsonwebtoken (Auth0)Node~10M weekly npm downloads
pyjwtPyJWTPythonMost-used Python lib; historical alg-confusion CVEs
pyjosepython-josePythonLooser parser; CVE-2024-33663 territory
panvajose (panva)NodeMost spec-compliant JS lib; oracle
gojwtgolang-jwt/jwt v5GoUsed in Kubernetes, Helm, etc.

The panva wrapper detects key type by content rather than by declared algorithm, so the library is given the same chance the others get to refuse a mismatched (key, alg) pair, important for not manufacturing alg-confusion divergences that are really wrapper artifacts:

async function importKey(keyMaterial, algs) {
 if (typeof keyMaterial === "string" && keyMaterial.includes("BEGIN")) {
 const asymAlgs = algs.filter((a) => /^(RS|PS|ES|Ed)/.test(a));
 const tryAlg = asymAlgs[0] || algs[0];
 return await jose
 .importSPKI(keyMaterial, tryAlg)
 .catch(async () => jose.importX509(keyMaterial, tryAlg));
 }
 if (typeof keyMaterial === "object" && keyMaterial !== null) {
 return await jose.importJWK(keyMaterial, algs[0]);
 }
 return new TextEncoder().encode(keyMaterial);
}

A v2 expansion is planned to add nimbus-jose-jwt (Java), the Rust jsonwebtoken, and lcobucci/jwt (PHP), broadening the cross-language surface where token bytes change hands.

Oracle design and divergence classes

The seed corpus carries 73 cases across 13 bug classes. Three are baseline positive controls (RS256, HS256, ES256 happy paths) whose job is to catch a broken or misconfigured wrapper before any negative case is trusted. The remaining classes target known JWT failure modes, each case tagged with the governing RFC clause or prior CVE:

ClassCasesWhat it probes
none-alg9alg:none case variants, none, None, NONE, NoNe
alg-confusion6HS256 signed against the RSA public key
crit-header5RFC 7515 §4.1.11 critical-header enforcement
kid-injection8SQL-i and path-traversal patterns in kid
key-injection3Embedded jwk / jku self-signed key trust
sig-mutation8Truncated, flipped, and stripped signatures
ecdsa-encoding3ECDSA r/s of zero, n, and n−1
claim-typing10exp/nbf/aud type coercion and overflow
header-quirk7Duplicate keys, NUL bytes, BOM, unicode in the header
format6Compact-serialization framing and padding edge cases
allowlist-edge3Algorithm allowlist bypass and empty-allowlist behaviour
b64-detached2RFC 7797 b64=false detached-payload handling

Each case records its bug-class tag, the inputs, the expected_unanimous outcome that should hold if every library agreed, and a notes pointer to the RFC clause or prior CVE that governs the correct behaviour. The oracle is deliberately conservative: it never asserts the spec-correct answer to a library, only that the libraries must answer in unison. The spec citation is brought in by hand at triage, once a real divergence has been isolated, which is what turns a flagged row into an advisory that can pick a winner.

F001, node-jsonwebtoken: RFC 7515 §4.1.11 crit not enforced

Affected: jsonwebtoken (npm), auth0/node-jsonwebtoken. Tested: 9.0.3 (latest at time of testing). Class: spec violation of RFC 7515 §4.1.11 (Critical Header Parameter).

Root cause. The library does not implement critical-header processing. RFC 7515 §4.1.11 is explicit: "If any of the listed extension Header Parameters are not understood and supported by the recipient, then the JWS is invalid." A signed JWS whose crit array lists an extension parameter the recipient does not understand MUST be rejected. jsonwebtoken instead accepts such tokens unconditionally whenever the signature is valid for the declared alg. Source confirmation: verify.js on master never references crit, RFC 7515 §4.1.11, RFC 7797, or b64; its verification path validates only alg, nbf, exp, aud, iss, sub, jti, nonce, iat, and maxAge. No code path inspects the crit array.

How Schism caught it. Running corpus case crit-crit-eca split the verifiers, panva and PyJWT 2.12.0+ correctly reject the same token jsonwebtoken accepts:

[schism] 4/5 targets up: ['nodejwt', 'pyjwt', 'pyjose', 'panva']
[BYPASS] crit-crit-eca sev=bypass-risk accept=['nodejwt', 'pyjose'] reject=['pyjwt', 'panva']

Per-library verdict on the case:

nodejwt 9.0.3 valid=True
pyjwt 2.12.0 valid=False InvalidJWTError: Token has unsupported critical header
pyjose 3.3.0 valid=True
panva 5.10.0 valid=False ERR_JOSE_NOT_SUPPORTED: Extension Header Parameter "foobar" is not recognized

Repro structure (sanitized). The test case constructs a normally HS256-signed token whose JOSE header additionally carries crit listing an unregistered extension name, with that extension also present in the header:

header = { alg: "HS256", typ: "JWT", crit: ["<ext>"], "<ext>": true }
claims = { sub: "alice", iat: ..., exp: ... }
token = sign(header, claims, secret) // signature is genuinely valid
verify(token, secret, { algorithms: ["HS256"] })
// observed: ACCEPTED. spec-correct: REJECTED (extension not supported)

Impact. The split is exploitable wherever a security-relevant extension is declared critical and the directive is silently dropped by the lenient verifier: RFC 7797 detached payloads (b64=false), RFC 9449 DPoP proof-of-possession binding declared via cnf, and custom application claims (e.g. crit:["x-tenant-pin"]). In a heterogeneous fleet, jsonwebtoken on a backend behind a panva gateway, the split-brain interpretation lets an attacker pass a token the strict hop would have refused.

F002, python-jose: RFC 7515 §4.1.11 crit not enforced

Affected: python-jose (PyPI), mpdavis/python-jose. Tested: 3.3.0 (latest, last released 2022). Class: spec violation of RFC 7515 §4.1.11 (Critical Header Parameter).

Root cause. The same defect as F001 in a second library. jose/jws.py on master never references crit, RFC 7515 §4.1.11, RFC 7797, or b64; _load() decodes the header JSON without inspecting crit, and _encode_header() passes arbitrary additional headers through with no validation. A signed JWS declaring an unknown critical extension is accepted as long as the signature is otherwise valid.

Maintenance caveat. python-jose has had no release since 3.3.0 (2022) and its GitHub advisory page shows zero published advisories, yet it remains widely deployed via transitive dependency chains (FastAPI-adjacent stacks, OAuth2 clients) and is not formally deprecated. A CVE here serves both as user notification and as input for downstream forks.

Same divergence, same case. crit-crit-eca produces the identical BYPASS row: accept=[nodejwt, pyjose], reject=[pyjwt, panva]. The standalone reproducer builds the token directly with the standard library, base64url JOSE header carrying crit:["<ext>"], base64url claims, and a genuine HMAC-SHA256 signature, then calls jose.jwt.decode(token, secret, algorithms=["HS256"]) and observes acceptance where RFC 7515 §4.1.11 requires the decode to raise. No forged signature and no key compromise is involved; the signature is valid by construction and the defect is purely the unenforced critical-header directive.

Impact. Identical to F001. Any application relying on crit to enforce a security-relevant extension cannot rely on python-jose to honor it; in deployments where one service uses python-jose and another uses a strict verifier (panva, PyJWT ≥ 2.12.0), the same bytes parse with different guarantees at different hops, the split-brain validation pattern documented in CVE-2025-59420 (Authlib).

Disclosure status

Both findings are the same defect, RFC 7515 §4.1.11 critical-header processing absent, in two libraries that remain unpatched. The defect is not hypothetical: the identical bug class was already disclosed and fixed in three sister libraries, which both confirms the impact and supplies the spec precedent that decides the correct behaviour.

LibraryAdvisoryFixed
PyJWTCVE-2026-32597 / GHSA-752w-5fwx-jx9f2.12.0
fast-jwtCVE-2026-35042per advisory
AuthlibCVE-2025-59420 / GHSA-9ggr-2464-2j32per advisory, CVSS 7.5
jsonwebtoken (F001)noneunpatched
python-jose (F002)noneunpatched

Each finding follows responsible-disclosure norms before broadening publication:

  1. Confirm the disagreement reproduces against the latest released version of each affected library.
  2. Confirm a spec citation that picks a winner, the RFC mandates X, the library does not implement X.
  3. File a GitHub Security Advisory at the affected repository.
  4. Request a CVE via the repository's CNA or MITRE.
  5. Wait for the upstream patch or embargo expiration before broadening publication.

F001 was filed via GitHub Security Advisory at auth0/node-jsonwebtoken and F002 at mpdavis/python-jose, both with CVEs requested via MITRE. The suggested remediation in both writeups mirrors panva's contract: parse the header, validate that crit is a non-empty array of strings each present in the header, forbid reserved RFC names from appearing in it, and reject any entry not in a caller-supplied allowlist of supported extensions exposed through the public verify API.

Repository · github.com/zionsworking/jwt-differential-fuzzer