DEV Community

Hammad Khan
Hammad Khan

Posted on

JWT Hardening Checklist: Beyond 'Use HS256'

JWT Hardening Checklist: Beyond "Use HS256"

Every codebase that uses JWTs has the same comment somewhere: "Using HS256 for now, can switch to RS256 later." That comment was right when it was written and it's wrong now, because "later" has been five years and nobody's revisited it.

This is a checklist of the JWT issues I've found across production codebases, in rough order of severity. Most teams have two or three of them. A few teams have all of them.

1. Algorithm pinning

The famous bug: a JWT library that accepts "alg": "none" and skips signature verification. Auditors have been chasing this for a decade and yet libraries still ship with permissive defaults.

The fix is to pin the algorithm at the verification call:

import jwt from 'jsonwebtoken'

const decoded = jwt.verify(token, SECRET, { algorithms: ['HS256'] })
Enter fullscreen mode Exit fullscreen mode

Note algorithms is an array, but you should pass exactly one. Multiple algorithms means the attacker chooses. They will choose none.

The Python equivalent (PyJWT):

decoded = jwt.decode(token, SECRET, algorithms=['HS256'])
Enter fullscreen mode Exit fullscreen mode

The Ruby equivalent (jwt gem):

decoded = JWT.decode(token, SECRET, true, { algorithm: 'HS256' })
Enter fullscreen mode Exit fullscreen mode

If your verify call doesn't pass algorithms explicitly, fix it. This is a five-character change and it closes a class of CVEs.

2. Symmetric vs asymmetric (HS256 vs RS256/ES256)

HS256 uses a shared secret. Whoever holds the secret can sign tokens. If you're signing tokens in one service and verifying them in another, both services need the secret. That means twice the attack surface for the secret, and rotation is twice the work.

RS256 uses an asymmetric keypair. The signer holds the private key. Verifiers hold the public key. Compromising a verifier doesn't let an attacker forge tokens (they only have the public key). For multi-service architectures, this is strictly better.

The migration is straightforward if you have the runway:

  1. Add asymmetric key generation to your auth service.
  2. Have your auth service start issuing JWTs signed with both algorithms (dual-sign during the rotation window — yes, you can include both signatures).
  3. Update verifiers to accept either.
  4. Once all verifiers are on the new code, drop the old HS256 signature.
  5. Once all in-flight HS256 tokens have expired, drop the verify support.

If you're a single-service app and you don't see a multi-service future, HS256 is fine. Don't migrate for migration's sake.

3. Expiry — and short expiry

Tokens should expire. The lifetime should be measured in minutes, not hours.

const token = jwt.sign(payload, SECRET, {
  algorithm: 'HS256',
  expiresIn: '15m',
})
Enter fullscreen mode Exit fullscreen mode

The argument I hear against short tokens: "the user will get logged out too often." This is what refresh tokens are for. Access tokens live 15 minutes. Refresh tokens live longer (hours to days) and live in HttpOnly cookies, where JS can't reach them. When the access token expires, the client calls a refresh endpoint, gets a new access token, and moves on.

If your tokens live 8 hours, an attacker who exfiltrates one has 8 hours of impersonation. If they live 15 minutes, they have 15 minutes. The math is straightforward.

4. Verify the issuer and audience

The iss (issuer) and aud (audience) claims are how you tell that a token was minted by the right authority and intended for your application:

const decoded = jwt.verify(token, SECRET, {
  algorithms: ['HS256'],
  issuer: 'https://auth.example.com',
  audience: 'api.example.com',
})
Enter fullscreen mode Exit fullscreen mode

If you don't verify these, a token issued by your auth server for one of your services can be replayed against a different service. Inter-service token reuse is a real attack pattern.

5. Don't put PII or secrets in the payload

JWT payloads are base64-encoded, not encrypted. Anyone with the token can decode and read the contents. This includes:

  • The user's email or name (PII).
  • Role information (which the attacker now knows).
  • Application-internal IDs (which the attacker can guess at).

Keep the payload minimal: user ID, issued-at, expiry, issuer, audience, maybe a session ID. Everything else, look up server-side from the user ID.

If you absolutely need an encrypted payload, use JWE instead of JWT. But the better answer is usually "don't put the data there."

6. Refresh tokens belong in HttpOnly cookies

Storing tokens in localStorage is the default in many tutorials and the default in many bugs. JS code can read localStorage. XSS gets you the tokens.

The better pattern:

  • Access token: short-lived (15 min), held in memory by the SPA, sent in Authorization: Bearer headers.
  • Refresh token: longer-lived, set as HttpOnly; Secure; SameSite=Lax cookie. JS can't read it. The browser sends it automatically on the refresh endpoint.

The refresh endpoint is the only one that reads the refresh token. Everything else uses the access token.

// Refresh endpoint
app.post('/auth/refresh', async (req, res) => {
  const refresh = req.cookies['refresh_token']
  if (!refresh) return res.status(401).end()
  try {
    const decoded = jwt.verify(refresh, REFRESH_SECRET, {
      algorithms: ['HS256'],
      issuer: 'https://auth.example.com',
      audience: 'api.example.com',
    })
    if (await isRevoked(decoded.jti)) return res.status(401).end()
    const access = signAccessToken(decoded.sub)
    res.json({ access_token: access })
  } catch {
    res.status(401).end()
  }
})
Enter fullscreen mode Exit fullscreen mode

7. Refresh token rotation

When the client uses a refresh token, give them a new refresh token in the response. Invalidate the old one. This is "refresh token rotation."

The win: if an attacker steals a refresh token and uses it, you'll notice when the legitimate client tries to use the same (now-invalidated) token next time. You can force-logout the entire session in response.

// On refresh, mint a new refresh too
app.post('/auth/refresh', async (req, res) => {
  const refresh = req.cookies['refresh_token']
  if (!refresh) return res.status(401).end()
  const decoded = jwt.verify(refresh, REFRESH_SECRET, { /* ... */ })
  if (await isRevoked(decoded.jti)) {
    // Refresh token reuse detected. Kill the entire session.
    await killSession(decoded.session_id)
    return res.status(401).end()
  }
  await markRevoked(decoded.jti)  // single-use
  const newRefresh = signRefreshToken(decoded.sub, decoded.session_id)
  const access = signAccessToken(decoded.sub)
  res.cookie('refresh_token', newRefresh, { httpOnly: true, secure: true, sameSite: 'lax' })
  res.json({ access_token: access })
})
Enter fullscreen mode Exit fullscreen mode

The jti (JWT ID) claim is what makes this work. Each refresh token has a unique ID. You track which IDs have been used (in Redis, with a TTL slightly longer than the refresh token's expiry). Reuse of a used ID is a sign of compromise.

8. Revocation

Stateless tokens can't be revoked by changing a database row. They're valid until they expire. This is a fundamental design constraint, and you work around it two ways:

  1. Short access tokens so the window of impersonation is small.
  2. A revocation list keyed by jti or session ID for cases where you need immediate revocation (logout, password change, role downgrade).

The revocation list is a small Redis SET. On every token verify, check the SET. If the jti is in the SET, reject the token regardless of signature validity.

async function verifyWithRevocation(token: string): Promise<Decoded | null> {
  const decoded = jwt.verify(token, SECRET, { /* ... */ })
  if (await redis.sismember('revoked_jti', decoded.jti)) return null
  return decoded
}
Enter fullscreen mode Exit fullscreen mode

The Redis hit costs about 0.1ms. The peace of mind is worth it.

9. Don't trust the kid claim blindly

If you support multiple signing keys (e.g., for rotation), tokens carry a kid (key ID) claim that says which key to use. A naive implementation does this:

function getKey(kid: string): string {
  return KEYS[kid]  // attacker controls kid; they can pick any key, including ones you've rotated out
}
Enter fullscreen mode Exit fullscreen mode

The fix: maintain an allowlist of currently-valid kid values. Reject any token whose kid isn't in the allowlist, before you do anything else.

const VALID_KIDS = new Set(['2026-q2', '2026-q1'])
function getKey(kid: string): string {
  if (!VALID_KIDS.has(kid)) throw new Error('invalid_kid')
  return KEYS[kid]
}
Enter fullscreen mode Exit fullscreen mode

For RS256/ES256 with JWKS endpoints, the library typically handles this, but verify your specific library's behavior.

10. Clock skew

The exp and nbf claims are time-based. If your servers' clocks drift, tokens issued by one server can be rejected by another. Allow a small clock skew:

jwt.verify(token, SECRET, {
  algorithms: ['HS256'],
  clockTolerance: 30,  // 30 seconds of skew allowed
})
Enter fullscreen mode Exit fullscreen mode

Don't make this too generous. 30 seconds is reasonable. 5 minutes is "you have a different problem to solve, please go fix NTP."

The checklist, in summary

  • Pin the algorithm at verify time. Never accept alg=none.
  • Use asymmetric keys (RS256/ES256) for multi-service architectures.
  • Short access tokens (15 min). Long refresh tokens in HttpOnly cookies.
  • Verify iss and aud. Always.
  • Don't put PII in the payload.
  • Rotate refresh tokens on each use. Track jti for reuse detection.
  • Maintain a revocation list for immediate kills.
  • Allowlist kid values.
  • Allow small clock skew (30s).

Each item is a small piece of code. The aggregate is the difference between "we use JWTs" and "we use JWTs correctly." If your codebase fails three or more of these, it's worth a Tuesday afternoon.

Top comments (0)