
Before JSON Web Tokens became widely adopted, most web applications relied on server-side sessions. Sessions work - but they introduce scalability headaches in distributed systems. JWTs solved this by making authentication stateless: all necessary information is embedded in the token itself, signed by the server. That sounds great. Until it's implemented poorly. Misconfigured JWT handling is one of the most common and dangerous security flaws in modern web apps. This post covers how JWTs work, real attack techniques with known CVEs, and concrete fixes across major frameworks.
A JWT consists of three base64url-encoded segments separated by dots.
HEADER.PAYLOAD.SIGNATUREThe header declares the token type and signing algorithm. The payload holds claims — user ID, role, expiry. The signature is computed over both using a secret or private key, and is the only thing preventing tampering.
// Header
{ "alg": "HS256", "typ": "JWT" }
// Payload
{ "user_id": 123, "role": "user", "exp": 1712000000 }
// Signature
HMACSHA256(base64url(header) + "." + base64url(payload), secret)The critical point: if the server doesn't rigorously validate the signature or lets the client influence how it's verified everything falls apart.
The JWT spec allows alg: none for unsecured tokens. Vulnerable servers accept these without any signature verification, enabling a full authentication bypass.
How it works:
1. Decode the token (base64url decode each segment)
2. Modify the payload — e.g. set "role": "admin"
3. Change the header to "alg": "none"
4. Re-encode and send with an empty signature: HEADER.PAYLOAD.
Fix — whitelist allowed algorithms server-side and never accept "none":
// Node.js
jwt.verify(token, secret, { algorithms: ["HS256"] });
// Python
jwt.decode(token, key, algorithms=["HS256"])Real CVE: CVE-2015-9235 — Auth0's jsonwebtoken library accepted alg: none tokens, allowing authentication bypass without a valid signature.
When a server uses RS256 (asymmetric), the public key is often published at /.well-known/jwks.json. If the server trusts the client-supplied alg field and falls back to HS256, an attacker can sign a forged token using the public key as the HMAC secret — and the server will verify it successfully.
How it works:
1. Obtain the server's public key from the well-known endpoint
2. Change the token header to "alg": "HS256"
3. Sign the forged token using the public key as the HMAC secret
4 .The server verifies using the public key under HS256 — it passes
Fix — never trust the client-supplied algorithm. Enforce it server-side:
// Java / Spring Boot
Jwts.parserBuilder()
.requireAlgorithm(SignatureAlgorithm.RS256)
.setSigningKey(publicKey)
.build();Real CVE: CVE-2016-10555 — jwt-simple for Node.js did not validate the algorithm field, enabling RS256 → HS256 confusion attacks.
The JWT spec supports an optional jwk header parameter that embeds the signing key directly in the token. Servers that trust this field allow an attacker to supply their own key and sign tokens with it.
{
"alg": "RS256",
"jwk": {
"kty": "RSA",
"n": "<attacker public key>",
"e": "AQAB"
}
}Fix: Ignore jwk, jku, and x5u headers from tokens entirely. Only load keys from your own trusted key store never from the token itself.
If the server uses the kid header to dynamically load a signing key without sanitization, an attacker can exploit path traversal or SQL injection to control which key is used.
Path traversal example:
{ "kid": "../../../dev/null" }
// Server does: fs.readFileSync("/keys/" + kid)
// Loads empty file → HMAC secret becomes an empty stringSQL injection example:
{ "kid": "' OR 1=1 --" }
// Unsanitized DB query → attacker controls which key is returnedFix — use an allowlist of valid key IDs:
// Node.js
const allowedKids = ["key-2024-01", "key-2024-02"];
if (!allowedKids.includes(kid)) throw new Error("Invalid kid");HS256 tokens signed with weak secrets like secret, password, or 123456 can be cracked offline. An attacker only needs a valid token and a wordlist.
john --wordlist=rockyou.txt jwt.txt
# or
hashcat -a 0 -m 16500 token.txt wordlist.txtFix — use a cryptographically random secret of at least 256 bits:
openssl rand -base64 64Store it in an environment variable. Never hardcode it in source files or commit it to version control.
Real CVE: CVE-2022-21449 ("Psychic Signatures") — Java's ECDSA verification accepted blank signatures in JDK 15–18, bypassing JWT validation entirely. A reminder that even standard library implementations can fail silently.
Below are minimal secure examples for each major framework. All of them share the same principles: enforce the algorithm, validate issuer and audience, and load secrets from environment variables only.
// Node.js / Express
jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ["HS256"],
issuer: "your-app",
audience: "your-client"
});
# Django (SimpleJWT)
SIMPLE_JWT = {
"ALGORITHM": "HS256",
"SIGNING_KEY": os.environ["JWT_SECRET"],
}# Laravel — .env
JWT_SECRET=long_random_secure_key_here
# Usage
JWTAuth::parseToken()->authenticate();// ASP.NET Core
services.AddAuthentication()
.AddJwtBearer(options => {
options.TokenValidationParameters = new TokenValidationParameters {
ValidateIssuerSigningKey = true,
ValidateIssuer = true,
ValidateAudience = true,
ValidAlgorithms = new[] { SecurityAlgorithms.HmacSha256 }
};
});// Spring Boot (Java)
Jwts.parserBuilder()
.setSigningKey(secretKey)
.requireIssuer("your-app")
.build()
.parseClaimsJws(token);Always do:
1. Whitelist allowed algorithms server-side — never trust the client-supplied alg field
2. Validate iss, aud, and exp claims on every request
3. Use secrets with at least 256 bits of entropy, stored in environment variables
4. Use an allowlist for kid values if you support multiple keys
Never do:
1. Trust client-controlled header fields: alg, kid, jwk, jku
2. Use weak or hardcoded secrets
3. Skip signature validation for any token, for any reason
JWT vulnerabilities are almost never about cryptography failing. They're about developers trusting input they shouldn't. The spec is flexible by design that flexibility is also the attack surface.
One more thing worth noting: a misconfigured JWT system is often harder to fix than sessions, because tokens can't be revoked without additional infrastructure. A token you signed yesterday is still valid tomorrow unless you've built a denylist. Keep that in mind when deciding whether JWTs are actually the right tool for your use case.
Do you like the post?
I post blogs—feel free to send feedback, suggestions, or just connect.