Client Area

JWT Security Best Practices — Replay, Refresh, Revocation

ByDomain India Team·DomainIndia Engineering
6 min read24 Apr 20263 views
# 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