Web Application SecurityAdvanced
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
## When JWTs are the right choice
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:**
**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
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.
| 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 |
| 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 |
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
Related Articles
Security Headers Explained: CSP, HSTS, X-Frame-Options, and More51
CSRF Tokens Deep Dive: PHP, Laravel, Express, and SameSite Cookies32
Secure Password Hashing: bcrypt, argon2, and What Never to Use27
Preventing SQL Injection in PHP and Node.js (with Real Code Examples)23
Preventing XSS (Cross-Site Scripting) in PHP and Node.js23