JWT (JSON Web Token) is the de-facto standard for modern API auth. Misused, it becomes the single biggest hole in your application. This article recaps how JWTs work and walks through five common attack vectors along with durable defences.

JWT Structure: Quick Recap

A JWT has three parts — Header.Payload.Signature — encoded with base64url and joined by dots. The header carries algorithm info (HS256, RS256), the payload carries claims, and the signature proves the header+payload were signed with the secret key.

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIiwibmFtZSI6ImFkbWluIn0.K7JmD...

Attack 1: alg:none

The JWT spec allows an none algorithm — an unsigned token. Some older libraries see that header and accept the token without verifying the signature. An attacker edits the payload, drops the signature and walks in.

# Attack
echo -n '{"alg":"none","typ":"JWT"}' | base64 | tr '/+' '_-' | tr -d '='
# eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0

# Swap payload
echo -n '{"sub":"1","role":"admin"}' | base64 | tr '/+' '_-' | tr -d '='

# Token: eyJhbGciOiJub25lIn0.eyJzdWIiOiIxIiwicm9sZSI6ImFkbWluIn0.
# Signature is empty, and some libraries accept alg:none as-is

Attack 2: Weak Secret

HS256 is symmetric — the same secret both signs and verifies. If the secret is weak (a dictionary word, a short string), an attacker cracks it with hashcat and forges any token.

# Attacker side
hashcat -a 0 -m 16500 token.jwt wordlist.txt
# Weak secrets like 'secret', 'secret123' or 'password' fall in seconds
// Generating a strong secret
require('crypto').randomBytes(64).toString('hex')
// 128 hex chars = 512 bits of entropy — not brute-forceable

Attack 3: Algorithm Confusion (RS256 to HS256)

RS256 is asymmetric — a private key signs, a public key verifies. If the attacker finds where the public key is published (jwks endpoint, a .pem file), they flip the header to HS256 and sign the token using the public key as the HMAC secret. If verify picks the algorithm from the token instead of fixing it, it accepts the forgery.

// VULNERABLE
jwt.verify(token, publicKey);  // alg read from the token!

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

Attack 4: JKU / X5U Injection

The jku header claim (JWK Set URL) tells the validator where to fetch the signing keys from. If the validator fetches that URL without checking it, the attacker hosts their own jku and slips in a key of their choice.

// Vulnerable validator
const header = JSON.parse(atob(token.split('.')[0]));
const jwks = await fetch(header.jku).then(r => r.json());  // attacker controls jku!

// Safe
const ALLOWED_JKU = 'https://auth.example.com/.well-known/jwks.json';
if (header.jku !== ALLOWED_JKU) throw new Error('Invalid JKU');

Attack 5: kid (Key ID) SQL Injection / Path Traversal

The kid claim selects which key to use. If the validator feeds it into a DB query or a file lookup, SQL injection or path traversal becomes possible.

// DANGER
const key = await db.query(`SELECT pubkey FROM keys WHERE id='${header.kid}'`);
// Attacker: kid = "' UNION SELECT 'my_public_key'--"

// SAFE
if (!/^[a-f0-9]{32}$/.test(header.kid)) throw new Error('Invalid kid');
const key = await db.query('SELECT pubkey FROM keys WHERE id=$1', [header.kid]);

Best Practices

  • Algorithm whitelist — pass algorithms to verify
  • Strong secret — 256+ bits of randomness, stored as an env var
  • Short expiry — access token 15 min, refresh token 7 days
  • Revocation — JWTs are stateless; handle logout via a blacklist or short TTL
  • HTTPS only — tokens must not travel over plaintext
  • HttpOnly cookie or Authorization: Bearer — never LocalStorage (XSS steals it)
  • Do not put sensitive data in the token — JWT is base64, not encrypted; anyone can read the payload
  • Check iss, aud, sub claims — reject tokens from a different tenant

Refresh Token Rotation

The common safe pattern: short-lived access tokens and longer-lived refresh tokens. Every refresh rotates — a new refresh is issued and the old one is invalidated. If the old refresh reappears, treat the account as compromised and terminate every session.

Alternative: Opaque Token + Redis

JWT's statelessness makes revocation hard. In many cases a better solution is a random 256-bit token stored in Redis as token:abc123 → {user_id, exp, scopes}. You can revoke instantly and the payload never leaks to the client.

Conclusion

JWT is a powerful tool, and like every powerful tool it is dangerous when misused. Understanding the five attacks above and applying the matching controls hardens your tokens considerably. When in doubt, stateful tokens (opaque + Redis) are a safer default.

Let us audit your auth architecture

Security review for JWT, OAuth2 and OIDC implementations Contact us

WhatsApp