JWT Authentication Best Practices: Algorithms, Storage, and Revocation

安全指南

The current state of JWT misuse

JWT has become the default "I need login" option, but in code reviews I keep seeing the same mistakes: stuffing the user's password into the payload, using a library version vulnerable to alg: none, storing tokens in localStorage and wondering why XSS stole them, or issuing tokens that never expire and thus can't be revoked.

JWT itself is fine; the problem is "assuming it's a drop-in session replacement." This article covers the points that actually bite in production.


1. What JWT actually is

A JWT has three parts joined by .: header.payload.signature.

  • header: algorithm and type, e.g. {"alg":"HS256","typ":"JWT"}
  • payload: claims, e.g. {"sub":"123","name":"Ada","exp":1700000000}
  • signature: signs the first two parts with a secret, preventing tampering

Key point: the payload is only Base64Url-encoded — anyone can decode and read it. It is not encryption. Never put passwords, ID numbers, or the token itself in it.

echo 'eyJzdWIiOiIxMjMifQ' | base64 -d
# → {"sub":"123"}

2. The three most common mistakes

Mistake 1: using JWT as a session

A traditional session stores state on the server; revoking a user means deleting the session server-side. JWT is stateless by default — the server doesn't store it, so "revocation" becomes hard. If you need frequent revocation or forced logout, JWT isn't simpler than session; it's messier (see section 6).

Mistake 2: sensitive data in the payload

Because the payload is readable, putting passwords, plaintext emails, or internal permission bits is a leak risk. Put only necessary, non-sensitive identifiers: sub (user ID), role, exp.

Mistake 3: tokens that never expire

No exp, or an exp years out, is a permanent pass. Once leaked, an attacker uses it forever.


3. Algorithm security: where attacks start

The none algorithm attack

Some older libraries skipped signature verification when alg: none. An attacker changes the header to {"alg":"none"} and leaves the signature blank — forging any payload.

Defense: the server must explicitly whitelist allowed algorithms and reject none:

jwt.verify(token, secret, { algorithms: ["HS256"] });

Never let the client decide the algorithm.

RS256 / HS256 confusion attack

The classic "algorithm confusion": the server verifies with an RSA public key (expecting RS256), but the attacker changes the header to HS256 and signs with the public key as the HMAC secret. Since public keys are often publicly fetchable, the attacker forges valid tokens.

Defense:

  • Pin both the algorithm and the key type at verification: RS256 verifies with the public key, HS256 with a symmetric secret.
  • Never use an RSA public key as an HMAC secret.
  • Use mature libraries (jsonwebtoken, jose) and pass algorithms explicitly.
jwt.verify(token, publicKey, { algorithms: ["RS256"] });

Key management

  • Symmetric (HS256) secrets must be long, random, and out of the repo (env vars / secret manager).
  • Asymmetric (RS256/ES256) private keys live only on the signing server; public keys can be distributed.
  • Support key rotation (kid header identifies the current key).

4. Expiry and refresh: the access + refresh design

Best practice splits tokens in two:

  • access token: short-lived (e.g. 15 min), used for API calls; small leak window.
  • refresh token: long-lived (e.g. 7 days), only to mint new access tokens; stored more carefully.
const accessToken = jwt.sign({ sub: user.id, role }, accessSecret, {
  algorithm: "RS256",
  expiresIn: "15m",
});
const refreshToken = jwt.sign({ sub: user.id, jti: refreshId }, refreshSecret, {
  algorithm: "RS256",
  expiresIn: "7d",
});

The refresh endpoint accepts only refresh tokens, and every refresh issues a new refresh token (rotation), invalidating the old one. So even if a refresh token leaks, using it once replaces it.


This is the XSS-vs-CSRF tradeoff.

The localStorage risk

Any JavaScript can read a token in localStorage. One XSS (even a vulnerable third-party script) and the token is exfiltrated to the attacker. localStorage can't be HttpOnly — it can't be marked "HTTP-only."

Put the access token in a cookie:

  • HttpOnly: JS can't read it; blocks XSS theft.
  • Secure: HTTPS only.
  • SameSite=Strict/Lax: blocks most CSRF.

The cost is handling CSRF (section 7). For most web apps this is the safer default over localStorage.

res.setHeader("Set-Cookie", [
  `access=${accessToken}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=900`,
]);

The compromise

  • SPA + same-origin backend: store access in an HttpOnly cookie; the frontend sends it via fetch automatically.
  • Mobile / third-party calls: use the Authorization header + short-lived access token; refresh via secure storage.

6. Revocation and blacklist: jti

Short-lived access + rotating refresh already controls most risk. But "kick this user out now"? Give each token a unique jti (JWT ID) and maintain a "revocation list" (Redis blacklist with TTL equal to the token's remaining life):

await redis.set(`blacklist:${jti}`, "1", "EX", remainingSeconds);
if (await redis.get(`blacklist:${jti}`)) throw new Error("revoked");

You don't need to store all tokens server-side, only the "revoked" ones — minimal cost. Note: the blacklist only covers "need to actively revoke" cases (ban, password change); normal expiry still relies on exp.


7. CSRF defense and SameSite

With cookies you must consider CSRF: an attacker lures the user's browser to send a request to your domain; the browser auto-attaches the cookie, performing actions as the user.

  • SameSite=Lax/Strict: modern browsers already block most cross-site cookie-bearing requests by default. Prefer SameSite=Lax.
  • CSRF Token: for sensitive writes, require a server-issued random token (double-submit).
  • Re-auth for sensitive ops: password change, transfer, etc. require a second factor beyond the cookie.
app.post("/api/transfer", requireReauth, handler);

8. Walkthrough: a Node/Express middleware

A minimal but safe guard middleware:

import jwt from "jsonwebtoken";

export function authGuard(publicKey: string) {
  return (req, res, next) => {
    const token = req.cookies?.access; // HttpOnly cookie
    if (!token) return res.status(401).json({ error: "no token" });

    let payload;
    try {
      payload = jwt.verify(token, publicKey, { algorithms: ["RS256"] });
    } catch {
      return res.status(401).json({ error: "invalid token" });
    }

    if (blacklist.has(payload.jti)) return res.status(401).json({ error: "revoked" });

    req.user = { id: payload.sub, role: payload.role };
    next();
  };
}

The companion refresh endpoint:

app.post("/api/refresh", async (req, res) => {
  const rt = req.cookies?.refresh;
  if (!rt) return res.status(401).json({ error: "no refresh" });
  const payload = jwt.verify(rt, refreshPublicKey, { algorithms: ["RS256"] });
  if (blacklist.has(payload.jti)) return res.status(401).json({ error: "revoked" });

  blacklist.add(payload.jti);                  // revoke old refresh
  const newAccess = issueAccess(payload.sub);
  const newRefresh = issueRefresh(payload.sub); // new jti
  res.setHeader("Set-Cookie", [accessCookie(newAccess), refreshCookie(newRefresh)]);
  res.json({ ok: true });
});

FAQ

Q1: JWT or session — which is better?

Depends. Need server-centric control, frequent kick-outs, lots of session state? Session fits. Need stateless, cross-service, mobile-friendly? JWT fits. Don't pick JWT just because it's trendy.

Q2: How long should the access token live?

5–15 min is reasonable for web apps, paired with refresh rotation. Too long = big leak window; too short = frequent refreshes hurt UX.

Q3: Why does my JWT library say "invalid algorithm"?

Usually the server expects RS256 but got HS256, or vice versa — the algorithm isn't pinned. Always declare algorithms: [...] explicitly, and verify RS256 with the public key.

Q4: How does the frontend get user info?

Don't decode the JWT for sensitive data (payload is readable but untrusted and may be stale). Have a /api/me endpoint return authoritative user info; the frontend stores only non-sensitive identifiers like user ID.

Q5: Can JWT be encrypted?

Yes — JWE (JSON Web Encryption) encrypts the payload. But most cases only need signing (tamper-proof). Encryption adds complexity; keeping sensitive data out is simpler than encrypting it.


When debugging a token and seeing what it actually carries, these ToolsKu tools come in handy:

  • JWT decode — paste a token to see header/payload (no signature check, view only)
  • JWT generator — fill your own header/payload/secret to make a test token
  • HMAC — manually verify an HS256 signature matches

JWT isn't a session replacement; it's a different tradeoff: you gain "stateless, distributable" but lose "easy revocation, opaque payload." Know what you gained and what you gave up, and you're actually using it well.

Try these browser-local tools — no sign-up required →

#JWT#认证#安全#Token#鉴权