Adversarial JWT library test harness: 8 libraries, 4 ecosystems, RFC 7515 compliance.
Nine attack variants. 72 test results. Six of eight libraries ship parsers that silently accept tokens with unprocessed critical extensions — a MUST-level RFC 7515 requirement that has been in the spec since 2015.
The companion article, Six of Eight JWT Libraries Ship Non-Compliant Parsers, is the writeup. This repo is the artifact. Every claim in the article traces to a committed test file and a row in results/matrix.json.
The corpus is static and committed: one JSON file per attack variant in fixtures/corpus/, built by fixtures/generate.js from the committed test-only keypairs in fixtures/keys/ (RSA-2048 and EC P-256). Every library sees the same tokens, so the comparison is clean.
Each harness is self-contained with its own dependency manifest. Harnesses run the corpus in default configuration — no extra options, no hardening, exactly what you get when you call the library's verify function with a key and a token.
| # | Variant | What it tests |
|---|---|---|
| 1 | RS256 to HS256 algorithm confusion | forged token signed with the server's RSA public key as an HMAC secret |
| 2 | alg:none |
unsigned token with no signature |
| 3 | alg case normalization |
NONE, nOnE, None, \nnone — does the parser normalize before checking? |
| 4 | jwk header injection |
embedded attacker-controlled public key |
| 5 | jku header injection |
URL pointing to an attacker-controlled JWK set |
| 6 | Empty signature | valid header.payload with a trailing dot and no signature bytes |
| 7 | kid SQL injection |
kid set to ' OR '1'='1 — does it propagate to a key lookup? |
| 8 | crit unknown extension |
token declaring crit:["x-custom"] with an unrecognized extension |
| 9 | Clock skew defaults | nbf in the future, exp in the past, with no tolerance configured |
| Library | Ecosystem | Version |
|---|---|---|
| jsonwebtoken | Node.js | 9.0.3 |
| jose | Node.js | 5.10.0 |
| fast-jwt | Node.js | 5.0.6 |
| PyJWT | Python | 2.13.0 |
| python-jose | Python | 3.5.0 |
| golang-jwt | Go | v5.2.2 |
| lestrrat-go/jwx | Go | v2.1.4 |
| Microsoft.IdentityModel | .NET | 8.18.0 |
- Node.js (fixtures and runner)
- Python 3 + pip
- Go
- .NET SDK
From the repo root:
./runner/run-all.shThis installs each harness's dependencies, runs every harness against the corpus, and writes result fragments to results/tmp/. It runs clean on macOS ARM64.
runner/aggregate.js reads all fragments and writes results/matrix.json:
library_versions— versions captured at run timeresults[variant][library]— one entry per library per variant, with three fields:result—SAFE(rejected correctly) orVULNERABLE(accepted a token it should have rejected), orERRORif the harness failednote— the library's actual error message or observed behaviorreproducer— the harness test file that produced the entry
{
"generated": "2026-05-31T13:27:12.308Z",
"library_versions": {
"jsonwebtoken": "9.0.3",
"jose": "5.10.0"
},
"results": {
"alg_confusion_rs256_hs256": {
"jsonwebtoken": {
"result": "SAFE",
"note": "jsonwebtoken rejected algorithm confusion: invalid algorithm",
"reproducer": "harnesses/node-jsonwebtoken/tests/01_alg_confusion.test.js"
}
}
}
}Re-aggregate without re-running the battery:
node runner/aggregate.jsfixtures/ # corpus + keys (committed)
corpus/ # one JSON file per attack variant
keys/ # test-only RSA-2048 / EC P-256 keypairs
harnesses/ # one self-contained harness per library (Node, Python, Go, .NET)
runner/ # run-all.sh orchestration + aggregate.js
results/ # matrix.json (committed); tmp/ fragments (gitignored)
tests/ # root-level test area; each harness owns its own tests
MIT. See LICENSE.