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 alg field 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 with express-jwt, you’d set algorithms: ["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 alg header 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 alg is HS256, the verification must use a shared secret. If alg is RS256, it must use a public key.
  • Fix: Configure your JWT library to only accept HS256 if you intend to use a shared secret, and only accept RS256 if you intend to use public/private keys. Crucially, do not allow the same key material to be used for both. For express-jwt with HS256: new jwt.expressjwt({ secret: 'my-super-secret-key', algorithms: ['HS256'] }). For RS256, you’d use publicKey: fs.readFileSync('public.key') and algorithms: ['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 32 to 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 exp claim and rejects expired tokens. Also, consider implementing checks for iat (e.g., not allowing tokens issued too far in the past) and jti if replay is a significant concern.
  • Fix: Ensure your JWT library is configured to validate the exp claim. For express-jwt: new jwt.expressjwt({ secret: 'my-super-secret-key', algorithms: ['HS256'], expiresIn: '1h' }). The expiresIn option automatically sets the exp claim. If you’re manually setting claims, ensure exp is 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.

Want structured learning?

Take the full Cryptography course →