JSON Web Tokens are everywhere in modern APIs, and most JWT bugs come from a handful of repeated mistakes. This guide walks through them one by one, with code you can reuse.
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 in PHP, Node.js and Python apps, and notes on where each piece runs on Domain India hosting.
When JWTs are the right choice
| Use case | JWT | Session cookie |
|---|---|---|
| Monolith web app | Works, but adds complexity | Simplest and safest |
| 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 (known since 2015, still found in hand-rolled verifiers):
An attacker crafts a JWT with header {"alg":"none"} and no signature. Old or misconfigured libraries accept it.
The "HS256 vs RS256" attack: if you use RS256 (asymmetric), a naive verifier might accept a token that the attacker signed with HS256, using your public key as the HMAC secret.
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 denylist (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, and a fast lookup per request. Redis is not available on Domain India shared hosting, so there use a database table with the jti and its expiry (and delete expired rows from a cron job), or run Redis on your own VPS.
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. Domain India hosting includes free SSL, so there is no reason to serve an API over plain HTTP.
- Short access token lifetime (15 min) limits replay window
- Sender-constrained tokens — bind the token to something only the real client has. Standards such as DPoP (RFC 9449) and mutual-TLS-bound tokens do this properly; a home-made browser fingerprint claim is weak and easy to copy
- 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.
Use an opaque user ID (a random UUID, not an email or phone number) as sub. If you truly need confidential claims, use an encrypted token (JWE), not a plain JWT.
Production JWT pattern — Node.js
import jwt from 'jsonwebtoken';
import { randomBytes, createHash } from 'crypto';
const sha256 = (s) => createHash('sha256').update(s).digest('hex');
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, jti: payload.jti };
next();
} catch (err) {
return res.status(401).send('Invalid token');
}
}
// 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 || stored.hash !== sha256(refreshToken)) {
// A revoked refresh token being reused suggests theft: revoke the whole family
await db.refreshToken.updateMany({ where: { userId: payload.sub }, data: { revoked: true } });
throw new Error('Revoked');
}
// Rotate: revoke the old refresh token, issue a new pair
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('Invalid refresh token');
}
});
// Logout
app.post('/auth/logout', authenticate, async (req, res) => {
await redis.setex(`blacklist:${req.user.jti}`, 15 * 60, '1'); // or a DB denylist row
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. ES256 (or EdDSA where your library supports it) gives smaller keys and signatures than RSA.
Common pitfalls
kid) in the header and keep versioned keys.iat for anti-replayiat alone is not enough; use short expiry, and track jti where replay matters.FAQ
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).
HS256 or RS256?
HS256 if one service verifies own tokens (simpler). RS256 if multiple services verify (microservices, SSO).
Token stolen — how to know?
Rotate refresh tokens and treat reuse of an already-used refresh token as theft: revoke that user's tokens and force a new login. Unusual IP or country changes are a useful extra signal, not proof.
Do I need a JWT library?
Yes, don't roll your own. jsonwebtoken or jose (Node.js), firebase/php-jwt (PHP) and PyJWT (Python) are widely used. On Domain India shared hosting, Composer isn't pre-installed, so run composer.phar over jailed SSH, or install PHP libraries on your own computer or in CI and upload the vendor folder.
What's PASETO?
"Platform-Agnostic Security Tokens" — JWT alternative with stricter algorithm choice. Good option for new projects; JWT still dominant.
Running this on Domain India
- PHP APIs on shared hosting (cPanel, DirectAdmin): firebase/php-jwt works. Run
composer.pharover jailed SSH, or buildvendor/locally and upload it. See PHP disabled functions for what else is switched off. - Node.js and Python APIs on shared hosting: use Setup Node.js App or Setup Python App in cPanel or DirectAdmin. See Deploy a Node.js app on shared hosting.
- Revocation store: there is no Redis on shared hosting or the App Platform. Use Option C (token version) or a database denylist table, or run Redis on a VPS.
- Scheduled cleanup: cron jobs on shared hosting run at most every 4 minutes, which is plenty for deleting expired denylist rows.
- Microservices with RS256/ES256 and Redis: a self-managed VPS gives you root access to run them.
Ready to ship a secure API? Choose a Domain India VPS for full control, read the OWASP Top 10 defence guide, or open a ticket if you have a hosting question.
A self-managed VPS with full root access lets you run Redis, background workers and multiple services side by side.
View VPS plans