Level 3 · 30 min
JWT Pitfalls: alg=none, key confusion, expiry
JSON Web Tokens are everywhere because they are convenient: stateless authentication, claims-based authorization, no DB hit per request. They are also the source of a long catalogue of historical vulnerabilities — almost all caused by the design choice of letting the token declare its own algorithm. Use JWTs, but understand the failure modes.
Anatomy and the alg=none Attack
A JWT is three base64url segments: header.payload.signature. The header looks like { "alg": "HS256", "typ": "JWT" }, the payload contains claims ({ "sub": "alice", "exp": 1700000000 }), and the signature is computed over header + '.' + payload using the algorithm declared in the header. The historical attack: the JWT spec includes alg=none for 'unsigned' tokens. Older libraries verified the signature only if the header said an algorithm was present — so an attacker would forge { "alg": "none" }.{ "sub":"admin" }. (no signature, just the trailing dot) and the library returned a verified token with admin privileges. The fix: explicitly allowlist algorithms server-side; never trust the header. Modern jjwt, jose, jsonwebtoken default-reject alg=none, but codebases that pinned old versions or wrote their own verifier still fall.
HS256 / RS256 Key Confusion
HS256 is symmetric (HMAC-SHA-256 with a shared secret). RS256 is asymmetric (RSA signature with a private key, verified with the public key). The attack: server is configured to verify with an RSA public key, expecting RS256. Attacker forges a token with header { "alg": "HS256" } and computes the signature using the public key (which is, by definition, public) as the HMAC secret. A library that selects the algorithm from the header treats the public key as an HMAC secret, the signature verifies, and the attacker logs in as anyone. The fix: pin the algorithm at the verification call site — verify(token, { algorithms: ["RS256"] }) — never let the header choose. Auth0, Microsoft, and dozens of OSS libraries shipped with this bug at some point. The same pattern applies to ECDSA / EdDSA — always pin.
Expiry, Refresh, and Revocation
JWTs are stateless: the server has no record of issued tokens. This means revocation (''log out everywhere,'' ''ban this user'') is hard. The standard solution is short-lived access tokens (5-15 minutes) plus longer refresh tokens (days, server-side stored, one-time-use, rotated on each refresh). When you log a user out, you delete their refresh token from the DB; their access token still works for up to 15 minutes, which is acceptable for most threat models. Skipping refresh and issuing 7-day access tokens means a stolen token grants 7 days of access — and you can not revoke it. exp and nbf claims must be validated by the verifier (''Not Before''). Clock skew tolerance: 60 seconds is reasonable; an hour means an expired token still works for an hour. For ''log everyone out,'' rotate the signing key — every token signed with the old key fails verification immediately. For ''ban this user,'' a small jti (JWT ID) blocklist with a TTL equal to the token expiry is cheap and stateless-ish.
Code example
// VULNERABLE — algorithm taken from header (Node, old jsonwebtoken)
const payload = jwt.verify(token, publicKey);
// If header says HS256, library uses publicKey as HMAC secret '->' attacker wins
// FIXED — pin algorithm explicitly
const payload = jwt.verify(token, publicKey, {
algorithms: ["RS256"] // hard-coded; ignore header alg
});
// VULNERABLE — decoding without verifying (very common bug)
const claims = jwt.decode(token); // no signature check at all!
if (claims.role === "admin") { /* trusted unsigned input */ }
// FIXED — verify, then read claims
const claims = jwt.verify(token, publicKey, { algorithms: ["RS256"] });
// Token issuance — short access + refresh
const access = jwt.sign({sub: userId, role}, privateKey, {
algorithm: "RS256",
expiresIn: "15m",
issuer: "https://auth.example.com",
audience: "api.example.com"
});
const refresh = crypto.randomBytes(32).toString("base64url");
await db.refreshTokens.insert({token: refresh, userId, expiresAt: now + 7*24*3600*1000});
// On refresh: rotate (invalidate old, issue new)