JWT Security: Common Mistakes That Lead to Authentication Bypass

The alg:none attack, weak secrets, JWKS spoofing, and how to validate JWTs correctly in Node and Python.

JSON Web Tokens are everywhere, and so are JWT vulnerabilities. The attacks aren’t theoretical—they’ve been used against real applications to bypass authentication entirely. This guide covers the most exploited JWT weaknesses and how to implement correct validation.

JWT Structure Refresher

A JWT is three base64url-encoded parts joined by dots:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0Iiwicm9sZSI6InVzZXIifQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
[header]                                .[payload]                                  .[signature]

The header specifies the algorithm. The signature proves the token was issued by someone with the signing key. When verification fails—or is skipped—the entire authentication model collapses.

Attack 1: The alg:none Bypass

Early JWT libraries respected an alg: "none" header field, meaning “this token is unsigned, accept it without verification.” An attacker could forge any payload:

import base64, json

# Craft a fake admin token with no signature
header = base64url_encode(json.dumps({"alg": "none", "typ": "JWT"}))
payload = base64url_encode(json.dumps({"sub": "admin", "role": "superadmin"}))
fake_token = f"{header}.{payload}."  # Empty signature

# Many old libraries would accept this

The fix: Always specify the exact algorithm you expect. Never read the algorithm from the token itself to decide how to verify it.

# PyJWT — explicitly specify algorithm
import jwt

# WRONG — trusts the token's alg header
decoded = jwt.decode(token, secret, algorithms=jwt.get_unverified_header(token)['alg'])

# CORRECT — hardcode the expected algorithm
decoded = jwt.decode(token, secret, algorithms=["HS256"])
// jsonwebtoken (Node.js)
// WRONG
const decoded = jwt.verify(token, secret);  // Uses whatever alg is in the header

// CORRECT
const decoded = jwt.verify(token, secret, { algorithms: ['HS256'] });

Attack 2: Weak Signing Secrets

HMAC-signed JWTs (HS256/HS384/HS512) are only as strong as the secret. Attackers can offline-brute-force tokens using tools like hashcat:

# hashcat mode 16500 = JWT
hashcat -a 0 -m 16500 captured.jwt /usr/share/wordlists/rockyou.txt

If the secret is secret, password, changeme, or any dictionary word, the token can be forged in seconds.

Generating a strong secret:

import secrets
# Minimum 256 bits for HS256
jwt_secret = secrets.token_hex(32)  # 64 hex chars = 256 bits
# Or generate with openssl
openssl rand -hex 32

Better: switch to RS256 (asymmetric)

With RSA, the private key signs tokens (held only by your auth server) and the public key verifies them (can be shared with any service). Stealing the public key gains nothing.

# Signing (auth server only)
from cryptography.hazmat.primitives import serialization
private_key = open('private_key.pem').read()
token = jwt.encode(payload, private_key, algorithm='RS256')

# Verifying (any service with the public key)
public_key = open('public_key.pem').read()
decoded = jwt.decode(token, public_key, algorithms=['RS256'])

Attack 3: Algorithm Confusion (RS256 → HS256)

This subtle attack targets applications that support both RSA and HMAC algorithms. The attacker takes the server’s public key (which is public!) and uses it as the HMAC secret, then sets alg: HS256 in their crafted token.

# Attack: sign a token with the PUBLIC key as HMAC secret
attacker_token = jwt.encode(
    {"sub": "admin", "role": "superadmin"},
    public_key_bytes,  # The server's public key — publicly available!
    algorithm="HS256"
)

If the server says “this token says HS256, let me verify with my known key” and uses the public key as the HMAC secret, verification passes.

The fix: Pin the algorithm in your verification code. Never allow the token to choose between RS256 and HS256 dynamically.

// Node.js — pin to RS256 only
const EXPECTED_ALG = 'RS256';

function verifyToken(token, publicKey) {
  return jwt.verify(token, publicKey, { 
    algorithms: [EXPECTED_ALG],
    issuer: 'https://auth.myapp.com',
    audience: 'myapp-api'
  });
}

Attack 4: JWKS Endpoint Spoofing

Some libraries fetch the signing key from a jku (JWK Set URL) or x5u field in the JWT header. An attacker can craft a token pointing to their own JWKS endpoint:

{
  "alg": "RS256",
  "typ": "JWT",
  "jku": "https://attacker.com/evil-keys.json"
}

The server fetches the attacker’s public key, and since the token was signed with the corresponding private key, verification succeeds.

The fix: Never use header-specified key URLs. Either hardcode the JWKS URL or maintain a strict allowlist.

# python-jose with explicit JWKS handling
from jose import jwt, jwk
import requests

JWKS_URL = "https://your-auth-server.com/.well-known/jwks.json"  # Hardcoded

def verify_token(token):
    # Fetch keys from trusted URL only — ignore jku in token
    jwks = requests.get(JWKS_URL).json()
    header = jwt.get_unverified_header(token)
    
    # Find matching key by kid
    key = next(k for k in jwks['keys'] if k['kid'] == header['kid'])
    return jwt.decode(
        token, 
        jwk.construct(key),
        algorithms=['RS256'],
        audience='myapp-api'
    )

Proper Validation Checklist

Every JWT verification should check:

decoded = jwt.decode(
    token,
    public_key,
    algorithms=["RS256"],      # Pin the algorithm
    issuer="https://auth.myapp.com",  # Verify iss claim
    audience="myapp-api",      # Verify aud claim
    options={
        "verify_exp": True,    # Reject expired tokens
        "verify_nbf": True,    # Reject not-yet-valid tokens
        "verify_iat": True,    # Verify issued-at
    }
)
// Node.js complete verification
const options = {
  algorithms: ['RS256'],
  issuer: 'https://auth.myapp.com',
  audience: 'myapp-api',
  clockTolerance: 30,  // 30 second tolerance for clock skew
};

try {
  const decoded = jwt.verify(token, publicKey, options);
  // decoded.sub is the verified user ID
} catch (err) {
  if (err.name === 'TokenExpiredError') {
    // Prompt re-authentication
  } else {
    // Treat as invalid — log the error
  }
}

Key Takeaways

  1. Always hardcode the expected algorithm — never trust the token’s alg header.
  2. Use asymmetric keys (RS256/ES256) for stateless tokens; symmetric secrets must be long and random.
  3. Disable jku/x5u header processing unless you have a strict allowlist.
  4. Validate iss, aud, and exp claims on every request, not just at login.
  5. Use a well-maintained library (PyJWT, python-jose, jsonwebtoken) and keep it updated — algorithm confusion bugs are library-level vulnerabilities.