The most surprising thing about JWT security vulnerabilities is how often they stem from developers treating JWTs as opaque tokens, ignoring the crucial cryptographic choices made during their creation.
Let’s see this in action. Imagine a user logs in, and the server issues a JWT. This token might look something like:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
The first part, eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9, is the header. If we decode it (it’s just Base64Url encoded JSON), we see:
{
"alg": "HS256",
"typ": "JWT"
}
The alg field here tells us the signing algorithm used. HS256 means HMAC with SHA-256. This algorithm uses a shared secret. Both the issuer and the verifier need this secret. If alg was RS256, it would use public/private key cryptography, where the issuer signs with a private key and the verifier checks with a public key.
The second part, eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ, is the payload, containing claims about the user:
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022
}
The third part, SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c, is the signature. This is generated by taking the encoded header and payload, and signing them with the secret (for HS256) or private key (for RS256).
The core problem JWT vulnerabilities exploit is trusting the alg header value without proper server-side validation.
1. The "None" Algorithm Vulnerability
This is perhaps the most infamous. If a server accepts a JWT with {"alg": "none"}, it means the token is not signed at all. The attacker can then forge any payload they want, including changing their user ID to an administrator’s, and the server will happily accept it because it expects no signature.
- Diagnosis: Inspect the
algfield in the JWT header. If it’s "none", and the server accepted it, you have this vulnerability. - Fix: In your JWT validation library (e.g.,
express-jwt,python-jose,java-jwt), explicitly configure the allowed algorithms. For Node.js withexpress-jwt, you’d setalgorithms: ["HS256", "RS256"](or whatever your actual allowed algorithms are). Never include"none"in the allowed list. - Why it works: This prevents the library from trusting the
algheader and forces it to use only the cryptographically secure algorithms you’ve whitelisted, ensuring the token’s integrity.
2. Alg Confusion (HS256 vs. RS256)
This happens when a server expects a token signed with HS256 (symmetric secret) but can be tricked into accepting a token signed with RS256 (asymmetric keys) using the public key as the shared secret.
Imagine the server expects alg: "HS256" and has a secret key, say my-super-secret-key. An attacker crafts a token with alg: "HS256" but signs it using the server’s public key (which is often publicly accessible or guessable) as the secret. When the server receives this token, it sees alg: "HS256", tries to verify the signature using the public key as the shared secret, and it will succeed because the public key is mathematically related to the private key used to sign. The attacker can then forge tokens.
- Diagnosis: Check if your JWT validation logic strictly enforces the algorithm and uses the correct key material for that algorithm. For example, if
algisHS256, the verification must use a shared secret. IfalgisRS256, it must use a public key. - Fix: Configure your JWT library to only accept
HS256if you intend to use a shared secret, and only acceptRS256if you intend to use public/private keys. Crucially, do not allow the same key material to be used for both. Forexpress-jwtwith HS256:new jwt.expressjwt({ secret: 'my-super-secret-key', algorithms: ['HS256'] }). For RS256, you’d usepublicKey: fs.readFileSync('public.key')andalgorithms: ['RS256']. - Why it works: This separation ensures that an attacker cannot substitute one type of key material (e.g., a public key) for another (e.g., a shared secret) to bypass signature verification.
3. Weak or Predictable Secrets/Keys
This isn’t strictly a JWT algorithm vulnerability, but it’s a critical security flaw that undermines JWTs. If your shared secret (for HS256) or private key (for RS256) is weak, easily guessable, or leaked, attackers can forge tokens.
- Diagnosis: Review your secret management. Are secrets stored in environment variables? Are they long, random strings? For asymmetric keys, is the private key kept absolutely secure?
- Fix: Generate strong, random secrets. For HS256, use a command like
openssl rand -base64 32to generate a 32-byte secret. Store secrets securely, ideally using a secrets management system or encrypted environment variables. For RS256, ensure your private key has strong permissions and is never exposed. - Why it works: Strong, unique secrets make brute-force attacks on the signature infeasible, preserving the integrity of the JWT.
4. Replay Attacks
Even with a valid, signed JWT, an attacker can sometimes reuse a captured token. This is mitigated by including time-based claims like iat (issued at) and exp (expiration time), and potentially a jti (JWT ID) for de-duplication.
- Diagnosis: Check if your JWT validation logic checks the
expclaim and rejects expired tokens. Also, consider implementing checks foriat(e.g., not allowing tokens issued too far in the past) andjtiif replay is a significant concern. - Fix: Ensure your JWT library is configured to validate the
expclaim. Forexpress-jwt:new jwt.expressjwt({ secret: 'my-super-secret-key', algorithms: ['HS256'], expiresIn: '1h' }). TheexpiresInoption automatically sets theexpclaim. If you’re manually setting claims, ensureexpis present and in the future. - Why it works: By enforcing expiration, you ensure that even if an attacker captures a token, it will eventually become invalid, preventing prolonged unauthorized access.
5. Incorrect Key Management for RS256
When using asymmetric algorithms like RS256, the server must correctly obtain and use the public key of the issuer for verification. If it mistakenly uses the private key, or a compromised public key, this breaks security.
- Diagnosis: Verify that your verification endpoint is configured to load and use the public key corresponding to the issuer’s private key. If the issuer and verifier are the same system, this is less of an issue, but if they are different, proper key distribution is paramount.
- Fix: Ensure the public key used for verification is correctly loaded from a trusted source. For example, if the issuer provides a JWKS (JSON Web Key Set) endpoint, use a library that can fetch and cache keys from there. For Node.js with
express-jwt-jwks:app.use(jwt({ secret: jwt.secretOrKey, algorithms: ['RS256'], issuer: 'your-issuer', audience: 'your-audience', jwksUri: 'https://your-issuer/.well-known/jwks.json' })). - Why it works: Using the correct public key ensures that only tokens signed by the corresponding private key can be verified, maintaining the integrity of the authentication process.
When you fix these, the next thing you’ll likely encounter is issues with clock drift between servers, leading to iat or exp claims being slightly off, causing valid tokens to be rejected.