Command Palette

Search for a command to run...

EN·ES

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.

Key Takeaways

  • Pin the algorithm server-side: verify(token, { algorithms: ["RS256"] }). Never let the header choose, ever.
  • Short access tokens (15 min) + rotated refresh tokens (7 days) + DB-tracked refresh tokens. Long access tokens are an unrevokable footgun.
  • Decoding != verifying. Libraries that expose decode() without a separate verify() step trap developers into reading claims from unverified tokens.

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)