JWT Security Best Practices — Replay, Refresh, Revocation
When JWTs are the right choice
| Use case | JWT | Session cookie |
|---|---|---|
| Monolith web app | Session cookie simpler | Overkill |
| API for mobile app | Good fit | Clumsy (cookies on mobile) |
| Distributed microservices | Good fit | Needs shared session store |
| Single-page app (SPA) | Popular choice | HttpOnly cookie is safer |
| Long-lived SSO across domains | Good | Harder |
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:
openssl rand -base64 64
# gives 64+ chars of cryptographic randomnessStore 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:
// 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:
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):
// 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_hashclaim, 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:
| Storage | XSS vulnerable | CSRF vulnerable | Pros | Cons |
|---|---|---|---|---|
| localStorage | Yes (any JS can read) | No | Easy | XSS = game over |
| HttpOnly cookie | No | Yes | Not readable by JS | Needs 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
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).
openssl genpkey -algorithm RSA -out private.pem -pkeyopt rsa_keygen_bits:2048
openssl rsa -in private.pem -pubout -out public.pemjwt.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
For SPAs and mobile — JWT. For traditional server-rendered apps — session cookie. For hybrid — use both (cookie for web, JWT for API).
HS256 if one service verifies own tokens (simpler). RS256 if multiple services verify (microservices, SSO).
Detect unusual IP/country/browser per user via fingerprint comparison. Force re-auth on mismatch.
Yes — don't roll your own. jsonwebtoken (Node), firebase/php-jwt (PHP), PyJWT (Python) are all battle-tested.
"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