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
// ❌ 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.
// 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.
# 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