JWT Pitfalls

The seven mistakes that turn a JWT implementation into an authentication bypass.

JWTs fail in a small number of well-documented ways. Every one of these has produced real CVEs.

1. Trusting the alg header

The token says how it is signed, and a naive verifier obeys. Set alg: "none", drop the signature, and some libraries historically accepted it. Always pass an explicit algorithm allowlist to your verify call.

2. Algorithm confusion (RS256 → HS256)

Your public key is, by design, public. If a verifier accepts HS256, an attacker signs a token using your public key as the HMAC secret — and a verifier that picks the algorithm from the header will happily verify it. The same fix: pin the algorithm.

3. Weak HS256 secrets

secret, changeme, or your app name are cracked offline in seconds with hashcat, from a single captured token. Use 256 bits from a CSPRNG, or use RS256.

4. Skipping aud and iss

Covered in the previous lesson: without them a valid token from a different service, or a different tenant, is accepted.

5. Long expiry

A 30-day access token is a 30-day breach. Access tokens belong in the 5–15 minute range; length comes from the refresh token, which you can actually revoke.

6. Sensitive data in the payload

Base64 is not encryption. Emails, phone numbers, internal ids and feature flags in a token are readable by the user and by anything that logs the header.

7. Decoding instead of verifying

Calling jwt.decode() instead of jwt.verify() — usually "just to read the user id" — accepts any string the client sends. This is a complete authentication bypass and it is astonishingly common.

The safe verify call

Pin the algorithm. Pass the issuer. Pass the audience. Set a small clock tolerance. Check expiry. Anything less and you are relying on defaults you did not read.

Example

Example · javascript
// ❌ Every one of these is exploitable
jwt.decode(token);                       // no verification whatsoever
jwt.verify(token, key);                  // algorithm taken from the header
jwt.verify(token, key, { algorithms: ['HS256', 'RS256'] });  // confusion window
jwt.sign(payload, 'secret');             // crackable offline in seconds
jwt.sign(payload, key, { expiresIn: '30d' });                // 30-day breach

// ✅ The shape a production call should have
jwt.verify(token, publicKey, {
  algorithms: ['RS256'],                 // exactly one, pinned
  issuer: 'https://auth.dfg.com',
  audience: 'https://dfg.com/api',
  clockTolerance: 30,
  maxAge: '15m',
});

When to use it

  • A penetration test forges an admin token by re-signing with alg switched to HS256 using the published public key, because the API accepted both algorithms.
  • A leaked staging token is cracked offline because the HS256 secret was the project name, and the same secret was reused in production.
  • A code review catches jwt.decode() used in middleware, which would have let anyone authenticate as any user by crafting a payload.

More examples

The algorithm confusion attack, concretely

The attacker needs nothing secret — the public key is published deliberately. The only defence is refusing to let the token pick the verification algorithm.

Example · javascript
// The server signs with RS256 and publishes public.pem — as it should.
// The mistake is a verifier that lets the TOKEN choose the algorithm.

// ❌ Vulnerable
function verifyBad(token) {
  return jwt.verify(token, PUBLIC_KEY);   // no algorithms option
}

// The attacker: forge a token signed with HS256, using the PUBLIC key as the
// HMAC secret. The verifier sees alg=HS256, uses PUBLIC_KEY as a shared secret,
// and the signature checks out.
const forged = jwt.sign(
  { sub: '1', scope: 'admin:all' },
  fs.readFileSync('public.pem'),          // public — anyone can fetch it
  { algorithm: 'HS256' },
);
// verifyBad(forged) → accepted. Full admin.

// ✅ Fixed by one option
function verifyGood(token) {
  return jwt.verify(token, PUBLIC_KEY, {
    algorithms: ['RS256'],                // HS256 is now rejected outright
    issuer: 'https://auth.dfg.com',
    audience: 'https://dfg.com/api',
  });
}

A safety checklist you can grep for

These five greps catch the majority of real JWT findings and take a minute to run — worth adding to CI as a blocking check rather than a review habit.

Example · bash
# Every jwt.verify / jwtVerify call must pass algorithms, issuer, audience
grep -rn "jwt.verify\|jwtVerify" src/ | grep -v "algorithms"

# jwt.decode in a request path is almost always a bug
grep -rn "jwt.decode\|decodeJwt" src/ --include='*.js' --include='*.ts'

# Hardcoded or short secrets
grep -rnE "(secret|SECRET)\s*[:=]\s*['\"][^'\"]{0,31}['\"]" src/

# Access tokens that live too long
grep -rnE "expiresIn:\s*['\"](\d+d|[2-9]\dh|[1-9]\d{2,}m)" src/

# And confirm what production actually issues
curl -s -X POST https://dfg.com/auth/login -d '...' \
  | jq -r .accessToken | cut -d. -f2 | base64 -d | jq '{alg_check: "see header", exp, aud, iss}'

Discussion

  • Be the first to comment on this lesson.