← THE INDEX  ·  VULN RESEARCH

JWT Differential Fuzzer

A differential harness that turns JWT library disagreements into auth-bypass findings.

The idea

A JWT library that accepts a token another major library rejects, given byte-identical (token, key, algorithm-allowlist) input, is either misimplementing the spec or reading it differently than the rest of the ecosystem. Either way, any application that passes tokens between services written in different languages can be split between accepting and rejecting verifiers. That asymmetry is an authentication-bypass primitive.

Wycheproof ships static test vectors. This harness runs the libraries live, in matched containers, against a corpus that grows over time.

How it works

Five JWT libraries each run as a minimal Docker container exposing one endpoint: POST /verify{"lib", "valid", "error"}. An async orchestrator fans every corpus case out to all five in parallel, then collapses the responses by their valid verdict. If the accept set and the reject set are both non-empty, the case is a disagreement, a finding. Error strings are bucketed rather than string-compared, so different wording across libraries never causes a false positive.

orchestrator/differ.py: the core comparison
TARGETS = {
    "nodejwt": "http://localhost:7001/verify",   # jsonwebtoken (Auth0)
    "pyjwt":   "http://localhost:7002/verify",   # PyJWT
    "pyjose":  "http://localhost:7003/verify",   # python-jose
    "panva":   "http://localhost:7004/verify",   # jose (panva): oracle
    "gojwt":   "http://localhost:7005/verify",   # golang-jwt/jwt v5
}

async def submit_all(session, payload, target_filter=None):
    tasks = [submit_one(session, n, u, payload)
             for n, u in TARGETS.items()
             if not target_filter or n in target_filter]
    return dict(await asyncio.gather(*tasks))

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   # accept-set and reject-set both non-empty

The bug-class corpus

The seed corpus pairs positive controls (RS256 / HS256 / ES256 happy paths) with a growing set of known JWT bug classes:

  • alg confusion: an HS256 token signed against the RSA public key
  • kid injection: SQL-i and path-traversal payloads in the kid header
  • jku spoof: external jku URL pointing at attacker-controlled JWKS
  • crit handling: RFC 7515 §4.1.11 critical-header enforcement
  • JWE/JWS confusion: a JWE token fed into a JWS verifier
  • ECDSA edge cases: r/s of zero, n, n−1
  • header JSON quirks: duplicate keys, NUL bytes, BOM, unicode

Each disagreement that survives triage becomes a written advisory in findings/, with a reproducing PoC.

Why it matters for a real org: microservice fleets routinely verify the same JWTs in Node and Go and Python. This harness is how you find the one library in that fleet that will accept a token the others won't, before an attacker does.