Client Area

JWT Security Best Practices — Replay, Refresh, Revocation

ByDomain India Team·DomainIndia Engineering
6 min readPublished 24 Apr 2026Updated 4 Jun 2026313 views

In this article

  • 1When JWTs are the right choice
  • 2Anatomy of a JWT
  • 3Pitfall 1 — Weak secret
  • 4Pitfall 2 — Algorithm confusion
  • 5Pitfall 3 — Missing expiration

JWT Security Best Practices — Replay, Refresh, Revocation

TL;DR
JSON Web Tokens (JWT) are convenient but dangerous when used wrong. This guide covers the real security pitfalls — secret strength, algorithm confusion, replay attacks, revocation strategies — with patterns that work on DomainIndia-hosted PHP, Node.js, and Python apps.

When JWTs are the right choice

Use caseJWTSession cookie
Monolith web appSession cookie simplerOverkill
API for mobile appGood fitClumsy (cookies on mobile)
Distributed microservicesGood fitNeeds shared session store
Single-page app (SPA)Popular choiceHttpOnly cookie is safer
Long-lived SSO across domainsGoodHarder

For most web apps, HttpOnly secure cookies are safer than localStorage JWTs (less XSS exposure). JWTs shine for stateless APIs consumed by mobile and SPAs.

Anatomy of a JWT

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsImV4cCI6MTczMDAwMDAwMH0.abc123
 └─ header ─────────────────────────┘ └─ payload ──────────────────────┘ └sig┘

Three base64url-encoded segments joined by dots. Header says algorithm, payload has claims, signature proves integrity.

Critical: signature != encryption. Payload is readable by anyone who sees the token.

Pitfall 1 — Weak secret

HS256 signs with a shared secret. If secret is short/weak, it's brute-forceable in minutes.

Bad: JWT_SECRET=secret123

Good: JWT_SECRET=<64+ random characters>

Generate:

bash
openssl rand -base64 64
# gives 64+ chars of cryptographic randomness

Store in env var, never commit to git, rotate on suspected compromise.

Pitfall 2 — Algorithm confusion

The "alg: none" attack (2015 but still common):

Attacker crafts a JWT with header {"alg":"none"} and no signature. Some libraries accept it.

The "HS256 vs RS256" attack: if you use RS256 (asymmetric) in production, a naive verifier might accept the same token signed with the public key using HS256.

Defense: always specify expected algorithm in verify:

javascript
// GOOD
const payload = jwt.verify(token, secret, { algorithms: ['HS256'] });

// BAD — accepts whatever alg the token claims
const payload = jwt.verify(token, secret);

Pitfall 3 — Missing expiration

Long-lived or never-expiring JWTs are a liability.

Good:

javascript
jwt.sign({ sub: userId, iat: Math.floor(Date.now() / 1000) },
  secret, { expiresIn: '15m' });

Access token: 15 minutes. Refresh token: 7-30 days.

If no expiration, a stolen token is good forever.

Pitfall 4 — No revocation strategy

JWTs are stateless — that's their strength AND weakness. Once issued, you can't invalidate them before expiry without extra machinery.

Options:

Option A — Short expiry + refresh tokens (recommended):

Access token 15 min, refresh 30 days. If user logs out or changes password, blacklist refresh token + mark all access tokens as stale server-side. Access tokens expire in ≤15 min naturally.

Option B — Token blacklist (Redis):

javascript
// On logout:
await redis.setex(`blacklist:${jti}`, expireSecondsRemaining, '1');

// On every verify:
if (await redis.exists(`blacklist:${payload.jti}`)) throw new Error('Revoked');

Needs jti (unique ID) in every JWT. Fast Redis lookup per request.

Option C — Version claim + user table:

Put tokenVersion: 1 in JWT. Store tokenVersion on user row. On verify, check payload.tokenVersion === dbUser.tokenVersion. On logout/compromise: bump user.tokenVersion. All old tokens now invalid.

Pitfall 5 — Replay attacks

Attacker intercepts a JWT and uses it. If it hasn't expired, it works.

Defenses:

  • HTTPS only — HTTP lets network attackers see tokens
  • Short access token lifetime (15 min) limits replay window
  • Bind token to client fingerprint — add browser_fingerprint_hash claim, reject if fingerprint changes
  • IP/country binding (risky — mobile IPs change) — bind token to network range, reject large jumps

Pitfall 6 — Storing JWT wrongly

Browser:

StorageXSS vulnerableCSRF vulnerableProsCons
localStorageYes (any JS can read)NoEasyXSS = game over
HttpOnly cookieNoYesNot readable by JSNeeds CSRF protection

Recommendation: HttpOnly + Secure + SameSite=Strict cookie. For cross-site SPA, SameSite=Lax + CSRF tokens.

Mobile app: Keychain (iOS) / Keystore (Android). Never SharedPreferences for tokens.

Pitfall 7 — Oversized payload

Cramming everything into JWT bloats every request.

Keep payload small:

  • User ID (sub)
  • Role / scope
  • Expiry (exp)
  • Token ID for revocation (jti)

Don't include: full user profile, permissions list, large arrays. Fetch those from DB on request.

Pitfall 8 — Leaking PII

JWT payload is base64 — trivially decodable. Don't put email, phone, Aadhaar in there.

If you must include identifiers: hash them. sub: sha256(userId) (one-way).

Production JWT pattern — Node.js

javascript
import jwt from 'jsonwebtoken';
import { randomBytes } from 'crypto';

const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET;
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET;

async function issueTokens(userId, userRole) {
  const jti = randomBytes(16).toString('hex');

  const accessToken = jwt.sign(
    { sub: userId, role: userRole, jti },
    ACCESS_SECRET,
    { algorithm: 'HS256', expiresIn: '15m' }
  );

  const refreshToken = jwt.sign(
    { sub: userId, jti, type: 'refresh' },
    REFRESH_SECRET,
    { algorithm: 'HS256', expiresIn: '30d' }
  );

  // Store refresh hash for revocation
  await db.refreshToken.create({
    data: {
      userId,
      jti,
      hash: sha256(refreshToken),
      expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
    },
  });

  return { accessToken, refreshToken };
}

// Middleware
async function authenticate(req, res, next) {
  const token = req.headers.authorization?.replace('Bearer ', '');
  if (!token) return res.status(401).send('No token');

  try {
    const payload = jwt.verify(token, ACCESS_SECRET, { algorithms: ['HS256'] });
    // Check blacklist
    if (await redis.exists(`blacklist:${payload.jti}`)) {
      return res.status(401).send('Revoked');
    }
    req.user = { id: payload.sub, role: payload.role };
    next();
  } catch (err) {
    return res.status(401).send(err.message);
  }
}

// Refresh endpoint
app.post('/auth/refresh', async (req, res) => {
  const { refreshToken } = req.body;
  try {
    const payload = jwt.verify(refreshToken, REFRESH_SECRET, { algorithms: ['HS256'] });
    const stored = await db.refreshToken.findUnique({ where: { jti: payload.jti } });
    if (!stored || stored.revoked) throw new Error('Revoked');
    // Optionally rotate: revoke old, issue new
    await db.refreshToken.update({ where: { jti: payload.jti }, data: { revoked: true } });
    const user = await db.user.findUnique({ where: { id: payload.sub } });
    const { accessToken, refreshToken: newRefresh } = await issueTokens(user.id, user.role);
    res.json({ accessToken, refreshToken: newRefresh });
  } catch (err) {
    res.status(401).send(err.message);
  }
});

// Logout
app.post('/auth/logout', authenticate, async (req, res) => {
  await redis.setex(`blacklist:${req.user.jti}`, 15 * 60, '1');
  await db.refreshToken.updateMany({
    where: { userId: req.user.id, revoked: false },
    data: { revoked: true },
  });
  res.json({ ok: true });
});

Asymmetric JWT (RS256 / ES256)

For microservices: one service signs (has private key), others verify (only need public key).

bash
openssl genpkey -algorithm RSA -out private.pem -pkeyopt rsa_keygen_bits:2048
openssl rsa -in private.pem -pubout -out public.pem
javascript
jwt.sign(payload, privateKey, { algorithm: 'RS256', expiresIn: '15m' });
jwt.verify(token, publicKey, { algorithms: ['RS256'] });

Verifiers can validate without access to the secret — compromise of one service doesn't expose the signing key.

Common pitfalls

FAQ

Q JWT or session cookie?

For SPAs and mobile — JWT. For traditional server-rendered apps — session cookie. For hybrid — use both (cookie for web, JWT for API).

Q HS256 or RS256?

HS256 if one service verifies own tokens (simpler). RS256 if multiple services verify (microservices, SSO).

Q Token stolen — how to know?

Detect unusual IP/country/browser per user via fingerprint comparison. Force re-auth on mismatch.

Q Do I need a JWT library?

Yes — don't roll your own. jsonwebtoken (Node), firebase/php-jwt (PHP), PyJWT (Python) are all battle-tested.

Q What's PASETO?

"Platform-Agnostic Security Tokens" — JWT alternative with stricter algorithm choice. Good option for new projects; JWT still dominant.

Secure your DomainIndia-hosted API with proper JWT patterns. Start with VPS

Was this article helpful?

Your feedback helps us improve our documentation

Still need help? Submit a support ticket