Web Application Security

JWT Security Best Practices — Replay, Refresh, Revocation

By Domain India Team · DomainIndia EngineeringPublished 9 min read
Knowledge base article
Contents (15 sections)

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.

Key takeaways

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 caseJWTSession cookie
Monolith web appWorks, but adds complexitySimplest and safest
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

code
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 (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:

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 denylist (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, 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:

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.

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

javascript
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).

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. ES256 (or EdDSA where your library supports it) gives smaller keys and signatures than RSA.

Common pitfalls

Using a default library secret
Some tutorials use "secret" literally. Search your codebase for obvious strings.
Not rotating secrets
Breach response needs key rotation. Design for it from day one: put a key ID (kid) in the header and keep versioned keys.
JWT in a URL query string
It ends up in server logs, browser history and referrer headers. Send it in the Authorization header.
Trusting iat for anti-replay
An attacker can reuse the same token until it expires. iat alone is not enough; use short expiry, and track jti where replay matters.
Ignoring clock skew
Servers with drifted clocks reject valid tokens. Allow 30 to 60 seconds of leeway in verify.
Refresh token in localStorage
Same XSS risk as the access token. Keep the refresh token in an HttpOnly cookie.

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.phar over jailed SSH, or build vendor/ 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.

Run your API on a VPS

A self-managed VPS with full root access lets you run Redis, background workers and multiple services side by side.

View VPS plans

Was this article helpful?

Your answer helps us decide what to improve next.

Still need help? Open a support ticket and our team will reply.

Prefer an app? Add this site to your home screen.Get the app