JWT Security: How Misconfigured Tokens Expose Your APIs
A JWT with a forged signature. No password needed, no exploit chain, just a change to the base64-encoded header and a stolen session. Auth0 shipped this bug to production. Attackers exploited it at scale. The token looked valid. The API accepted it.
The problem is not the JWT format itself. The only real protection the token has, signature verification, can be disabled by misconfiguration. And popular libraries have made it trivially easy to get wrong.
JWTs Are Trusted by Design. That Is the Problem
A JWT has three parts: header, payload, and signature. The signature is the only security control. Without it, anyone who decodes the token sees the claims in plaintext and can modify them freely.
The design is stateless by nature. The server holds no session state and cannot revoke individual tokens without additional infrastructure. Who signed the token and when are the only verifiable facts.
Decoding a JWT is trivial. Verifying the signature requires an explicit library call. The distinction between those two operations is where most bugs live.
Historically, jsonwebtoken and jose accepted alg:none by default. An unsigned token was valid. The library decoded the payload and returned it as authenticated without verifying anything. That was not an obscure bug. It was the default behavior of libraries used in millions of applications.
The second structural problem: many languages separate decode from verify into distinct functions. Python has jwt.decode() and jwt.decode(options={"verify_signature": False}). PHP has Firebase\JWT\JWT::decode(), which accepts an array of allowed algorithms. When documentation shows incomplete examples or a developer is rushing, the verification step disappears silently. The application works. Tests pass. The vulnerability persists.
Three Misconfigurations That Break Authentication Completely
alg:none
Change the alg field in the header to none, remove the signature, and send the token. In vulnerable implementations, the server accepts it. No key required. No access to the private key. Just base64 editing.
# Decode the original header
echo "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9" | base64 -d
# {"alg":"RS256","typ":"JWT"}
# Build a new header with alg:none
echo -n '{"alg":"none","typ":"JWT"}' | base64 | tr -d '='
# eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0
# Assemble the token without a signature (trailing empty dot)
TOKEN="eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxMjM0IiwicGF5bG9hZCI6ImFkbWluIn0."
curl -H "Authorization: Bearer $TOKEN" https://api.alvo.com/admin
Auth0 had this problem in production. The variant alg:nonE (uppercase E) bypassed the simple string check that would normally block none. A case-sensitive comparison broke the entire protection.
Algorithm Confusion: RS256 to HS256
RS256 uses a private key to sign and a public key to verify. The public key is, by definition, public and frequently exposed at the application's JWKS endpoint.
HS256 uses a symmetric key. The same secret signs and verifies.
When an API configured for RS256 accepts HS256 tokens, the attacker uses the server's public key as the HMAC secret. The signature is valid because the server uses that same public key to verify. CVE-2024-54150 documents exactly this pattern in Comcast's xmidt platform, published in 2024. Not in 2010. In 2024. The vector has existed for over a decade and keeps getting introduced into production.
Weak Secrets in HS256
Hashcat in mode 16500 attacks HS256 tokens directly. A captured token with a 12-character dictionary-based secret falls in minutes on modern hardware.
hashcat -a 0 -m 16500 captured.jwt /usr/share/wordlists/rockyou.txt
Red Sentry documented that 16-character secrets are crackable in under 24 hours with current hardware. That includes secrets that look strong but follow predictable patterns like MyAppSecret2024 or jwt-secret-prod. Secrets derived from product names, dates, or any human-readable string fall into that category.
Missing Claims
A missing exp claim means the token never expires. Compromised credentials cannot be rotated. An account compromised six months ago still holds a valid token today.
A missing aud claim means a token issued for service A is accepted by service B. Lateral movement with no additional exploit. Just token reuse across microservices in the same organization.
Each of these four vectors alone enables complete authentication bypass. Production APIs frequently present more than one at the same time. The team that got alg:none wrong probably also skipped aud validation.
Not Theoretical: CVEs With Real Production Impact
CVE-2022-21449 affected Java 15, 16, 17, and 18 before the April 2022 patch. The bug was in the JVM's ECDSA implementation. A signature with r=0, s=0 passed mathematical validation. Any JWT signed with ES256, ES384, or ES512 on a vulnerable JVM was forgeable with those two values. CVSS 7.5, remotely exploitable, no authentication required. com.nimbusds.jose fixed it in version 9.22. com.auth0:java-jwt fixed it in 3.19.2. Applications running earlier versions stayed vulnerable for months after the public patch because security dependency updates are not automatic.
CVE-2018-6873 in Auth0. The aud parameter was not validated. An attacker with any Auth0 account used their own token to access any account on the platform. All they needed was the target's email address. (CVE-2018-6874, from the same API, is a distinct CSRF vulnerability — not to be confused with the audience bypass.) Complete admin takeover without knowing any victim credentials. It affected every application protected by Auth0 during that period. The impact was not limited to a bug in one specific application. It was systemic across the platform's entire customer base.
CVE-2024-54150 in Comcast's xmidt-org/cjwt. Classic algorithm confusion, the same RS256/HS256 pattern. Published in 2024, confirming that this vector keeps getting introduced into production. Comcast's code likely went through review. It likely has tests. The vulnerability existed anyway because functional tests do not detect algorithm confusion.
Red Sentry counted multiple new critical JWT-related CVEs in 2025 alone (Red Sentry, 2025), affecting cloud platforms and enterprise systems. This vulnerability class is not going away. It keeps reproducing because every new JWT implementation reinvents the same mistakes.
How to Detect Before Attackers Do
Manual testing requires base64 and curl. Nothing else.
Test alg:none:
- Capture a valid JWT from any authenticated response
- Decode the header:
echo "<header_part>" | base64 -d - Build a new header with
"alg":"none"and encode it back - Assemble the token with the original payload but without a signature (end with an empty dot)
- Send it to the protected endpoint. If it accepts, the endpoint is vulnerable.
Test algorithm confusion:
- Obtain the server's public key (JWKS endpoint,
.well-known/jwks.json, or documentation) - Re-sign the token with HS256 using the public key as the HMAC secret
- Send it to the endpoint
For automation, jwt_tool covers these vectors and more in batch mode:
python3 jwt_tool.py <token> -X a # algorithm confusion
python3 jwt_tool.py <token> -X n # none attack
python3 jwt_tool.py <token> -C -d wordlist.txt # crack HS256 secret
python3 jwt_tool.py <token> -I -hc alg -hv none # manual header injection
Burp JWT Scanner detects CVE-2022-21449 passively. It intercepts tokens in responses and tests attack variants in active mode. In an API assessment, running the active scanner against every endpoint that returns a JWT takes minutes and covers known classes without manual configuration.
The kid (Key ID) field deserves separate attention. It is frequently used as a parameter in database queries without sanitization. SQLi via the kid header is a documented vector with public PoCs. Variants include JKU and X5U header injection for key substitution, where the attacker points the token at an external JWKS under their control. TrustedSec has a complete methodology covering these advanced attacks.
The jwt_scanner (intel.mago.team/spells) automates detection of those three vectors in a single scan: it crawls exposed endpoints, detects JWTs in the Authorization header and Set-Cookie, applies algorithm confusion and alg:none tests, and correlates with the CVE database to identify affected libraries by version.
JWT Is Not the Problem. Implementation Is.
JWT is not inherently insecure. The strongest argument against this analysis is that the same blind-trust vector exists in session tokens: a compromised Redis instance exposes sessions the same way a weak secret exposes tokens. Misconfiguration, not the format, is the attack surface in both cases. The difference is that JWT externalizes state. Any service that accepts the token without verifying the algorithm becomes a failure point independent of the issuer. Centralized sessions fail at one point. Poorly verified tokens fail at every service that consumes them.
Remediation: The Checklist That Closes the Surface
Four controls. All mandatory.
1. Algorithm pinning on the server
Never read the alg field from the token to decide which algorithm to use for verification. The server hardcodes the algorithm. The token has no say in that decision.
// WRONG: client dictates the algorithm
jwt.verify(token, secret)
// CORRECT: server decides
jwt.verify(token, publicKey, { algorithms: ['RS256'] })
# PyJWT
jwt.decode(token, public_key, algorithms=["RS256"])
# Never:
# jwt.decode(token, public_key, algorithms=jwt.get_unverified_header(token)["alg"])
2. Real entropy for HS256 secrets
Minimum 256 bits generated cryptographically. No passwords, phrases, or human-readable strings.
openssl rand -base64 32
Store it in a secrets manager (AWS Secrets Manager, HashiCorp Vault, or equivalent). Rotate every 90 days. Never commit it to a repository.
3. Explicit validation of all claims
jwt.verify(token, publicKey, {
algorithms: ['RS256'],
issuer: 'https://auth.sua-api.com',
audience: 'https://api.sua-api.com',
clockTolerance: 30
})
Reject tokens without exp. Reject tokens with exp in the past. Reject tokens with an aud that does not match your service. Reject tokens with an unknown iss. The library does not do this automatically. You configure it explicitly or you skip it.
4. Short lifetime and revocation for critical cases
Access token: maximum 15 minutes. Refresh token: maximum 24 hours. Both require a mandatory exp.
For security events (logout, password change, privilege de-escalation), maintain a jti (JWT ID) blocklist with TTL equal to the token lifetime. Stateless is convenient, not dogma. Redis with TTL handles selective revocation without complex architecture.
Asymmetric over symmetric in multi-service architectures
If more than one service consumes your tokens, use RS256 or ES256. The private key stays only in the issuer service. Consumer services verify with the public key. RS256/HS256 algorithm confusion is not possible in this architecture when each service hardcodes the expected algorithm.
JWT security is not a library decision. It is operational discipline. The attack surface closes when algorithm pinning, secret hygiene, and claims validation are verified continuously. The same vulnerabilities reappear in 2024 because teams do not scan what they have implemented. Scanning is the missing step between implementing and trusting.
Top comments (0)