JWT Security Best Practices for Developers — A Practical Checklist

JSON Web Tokens are everywhere — API authentication, single sign-on, mobile backends, microservices. They are also one of the most commonly misconfigured pieces of a modern stack. Most real-world JWT breaches do not involve breaking cryptography; they exploit weak secrets, missing claim validation, or verification code that trusts whatever algorithm the token declares. This guide walks through the practices that actually prevent those failures, with working Node.js examples using the popular jsonwebtoken library.

1. Use Strong Secrets and the Right Key Type

An HS256 token is only as strong as its secret. Short, guessable secrets like secret123 can be cracked offline in minutes with tools like hashcat — the attacker only needs one valid token to brute-force against. Use at least 256 bits (32 bytes) of randomness, load it from environment variables, and rotate it periodically.

// Generate a strong secret once and store it in your env / secret manager
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

// app.js
const jwt = require('jsonwebtoken');
const SECRET = process.env.JWT_SECRET; // never hard-code

const token = jwt.sign({ sub: user.id, role: user.role }, SECRET, {
  algorithm: 'HS256',
  expiresIn: '15m',
  issuer: 'https://api.example.com',
  audience: 'example-web-app',
});

If multiple services need to verify tokens, prefer asymmetric algorithms (RS256 or ES256). The auth server keeps the private key; every other service only holds the public key, so a compromised downstream service cannot mint new tokens.

2. Defend Against Algorithm Confusion Attacks

The alg field in a JWT header is attacker-controlled input. Two classic attacks abuse verification code that trusts it:

  • The "none" algorithm — the attacker sets alg: none and strips the signature. Old or misconfigured libraries accept the token as valid with no signature check at all.
  • HS256/RS256 swap — against a server that verifies RS256 tokens, the attacker crafts an HS256 token signed with the server's public key as the HMAC secret. If the code passes the public key to a generic verify function, the forged signature validates.

The fix is the same for both: never let the token choose. Pin the algorithms you accept.

// BAD: accepts whatever alg the token declares (in old library versions)
jwt.verify(token, key);

// GOOD: explicitly whitelist the expected algorithm
jwt.verify(token, publicKey, { algorithms: ['RS256'] });

// GOOD for HMAC setups
jwt.verify(token, SECRET, { algorithms: ['HS256'] });

3. Keep Expiry Short and Use Refresh Tokens

A JWT is valid until it expires — the server usually has no memory of issuing it. If a token with a 24-hour lifetime is stolen, the attacker has a 24-hour session you cannot easily terminate. The standard mitigation is a two-token design:

  • Access token — short-lived (5–15 minutes), sent with every API request.
  • Refresh token — longer-lived (days), stored server-side or in an httpOnly cookie, used only to obtain new access tokens, and rotated on every use so a replayed refresh token can be detected.
const accessToken = jwt.sign({ sub: user.id }, SECRET, { expiresIn: '15m' });

// Refresh tokens: opaque random values stored in the database work best,
// because they can be revoked instantly
const refreshToken = require('crypto').randomBytes(40).toString('hex');
await db.refreshTokens.insert({
  token: hash(refreshToken),
  userId: user.id,
  expiresAt: Date.now() + 7 * 24 * 3600 * 1000,
});

4. Choose Storage Deliberately: httpOnly Cookie vs localStorage

Where the browser keeps the token decides which attack class you are exposed to:

  • localStorage / sessionStorage — easy for SPAs, but readable by any JavaScript on the page. One XSS bug and the token is exfiltrated. Never store refresh tokens here.
  • httpOnly cookie — invisible to JavaScript, so XSS cannot steal it directly. The trade-off is CSRF exposure, mitigated with SameSite=Lax or Strict plus CSRF tokens for state-changing requests.
// Express: set the token as a hardened cookie
res.cookie('access_token', accessToken, {
  httpOnly: true,   // not readable via document.cookie
  secure: true,     // HTTPS only
  sameSite: 'lax',  // blocks most CSRF vectors
  maxAge: 15 * 60 * 1000,
});

A common hybrid: access token in memory (a JavaScript variable, lost on refresh) and refresh token in an httpOnly cookie. Nothing sensitive ever touches localStorage.

5. Validate aud, iss, and Every Claim You Depend On

Signature verification proves who signed the token — not that the token was meant for your service. Without audience checks, a valid token issued for Service A can be replayed against Service B that shares the same identity provider. Always verify:

try {
  const payload = jwt.verify(token, publicKey, {
    algorithms: ['RS256'],
    issuer: 'https://auth.example.com',   // who minted it
    audience: 'orders-api',                // who it is for
    clockTolerance: 5,                     // seconds of clock skew allowed
  });
  // additional business checks
  if (!payload.sub) throw new Error('missing subject');
} catch (err) {
  return res.status(401).json({ error: 'invalid token' });
}

Also remember: the payload is only Base64Url-encoded, not encrypted. Anyone holding the token can read it, so never place passwords, card numbers, or personal data inside.

6. Plan for Revocation Before You Need It

Stateless tokens cannot be un-issued, so build a revocation path up front:

  • Short expiry — the simplest control; a stolen 10-minute token limits the blast radius on its own.
  • Denylist by jti — give each token a unique jti claim and keep revoked ids in Redis until they expire. Lookup is one fast key check per request.
  • Token versioning — store a tokenVersion per user; bump it on password change or "log out everywhere" and reject tokens carrying an older version.
  • Revoke the refresh token — since access tokens die quickly, killing the refresh token effectively ends the session.

Frequently Asked Questions

What is the algorithm confusion attack in JWT?

It tricks a server into verifying a token with the wrong algorithm — accepting alg: none (no signature) or verifying an HS256 token using a public RSA key as the HMAC secret. Pin the expected algorithm in your verification code to prevent it.

Should I store JWT in localStorage or cookies?

httpOnly cookies are generally safer because JavaScript cannot read them, protecting tokens from XSS theft. localStorage is simpler for SPAs but any XSS bug exposes the token. With cookies, add SameSite and CSRF protection; with localStorage, keep token lifetimes very short.

How long should a JWT access token be valid?

Keep access tokens short-lived — 5 to 15 minutes is common. Pair them with longer-lived refresh tokens that are stored securely and rotated on use, so users stay logged in without a stolen access token remaining usable for hours.

Try the Free JWT Decoder

Paste any JWT and inspect its header, payload, and expiry claims instantly — all in your browser, nothing sent to a server. No signup, no cost.

Related articles