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.
| ID | Library | Language | Rationale |
|---|---|---|---|
nodejwt | jsonwebtoken (Auth0) | Node | ~10M weekly npm downloads |
pyjwt | PyJWT | Python | Most-used Python lib; historical alg-confusion CVEs |
pyjose | python-jose | Python | Looser parser; CVE-2024-33663 territory |
panva | jose (panva) | Node | Most spec-compliant JS lib; oracle |
gojwt | golang-jwt/jwt v5 | Go | Used 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:
| Class | Cases | What it probes |
|---|---|---|
none-alg | 9 | alg:none case variants, none, None, NONE, NoNe |
alg-confusion | 6 | HS256 signed against the RSA public key |
crit-header | 5 | RFC 7515 §4.1.11 critical-header enforcement |
kid-injection | 8 | SQL-i and path-traversal patterns in kid |
key-injection | 3 | Embedded jwk / jku self-signed key trust |
sig-mutation | 8 | Truncated, flipped, and stripped signatures |
ecdsa-encoding | 3 | ECDSA r/s of zero, n, and n−1 |
claim-typing | 10 | exp/nbf/aud type coercion and overflow |
header-quirk | 7 | Duplicate keys, NUL bytes, BOM, unicode in the header |
format | 6 | Compact-serialization framing and padding edge cases |
allowlist-edge | 3 | Algorithm allowlist bypass and empty-allowlist behaviour |
b64-detached | 2 | RFC 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 recognizedRepro 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.
| Library | Advisory | Fixed |
|---|---|---|
PyJWT | CVE-2026-32597 / GHSA-752w-5fwx-jx9f | 2.12.0 |
fast-jwt | CVE-2026-35042 | per advisory |
Authlib | CVE-2025-59420 / GHSA-9ggr-2464-2j32 | per advisory, CVSS 7.5 |
jsonwebtoken (F001) | none | unpatched |
python-jose (F002) | none | unpatched |
Each finding follows responsible-disclosure norms before broadening publication:
- Confirm the disagreement reproduces against the latest released version of each affected library.
- Confirm a spec citation that picks a winner, the RFC mandates X, the library does not implement X.
- File a GitHub Security Advisory at the affected repository.
- Request a CVE via the repository's CNA or MITRE.
- 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.