All posts
SecurityJun 6, 2026·8 min read

JWT Authentication Best Practices

Most JWT implementations have security flaws. Algorithm confusion attacks, storing tokens in localStorage, missing expiry, improper revocation — here's how to do it right.

Understanding JWTs: what they are and aren't

A JSON Web Token (JWT) is a Base64-encoded, digitally signed payload. The key insight most developers miss: JWTs are signed, not encrypted. Anyone who obtains a JWT can decode the payload and read its contents — the signature only proves it hasn't been tampered with.

// A JWT has three parts separated by dots:
// eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiJ1c3JfMTIzIn0.abc123

// Decode (no secret needed — anyone can do this):
const [header, payload] = token.split('.').slice(0, 2);
console.log(JSON.parse(Buffer.from(header, 'base64url').toString()));
// { alg: 'HS256', typ: 'JWT' }

console.log(JSON.parse(Buffer.from(payload, 'base64url').toString()));
// { userId: 'usr_123', role: 'admin', iat: 1718899200, exp: 1718900100 }

The value of a JWT is in the signature: the server can verify the token came from itself and hasn't been modified, without a database lookup. This makes JWTs fast for stateless authentication but fragile for revocation — you can't "delete" a JWT; it's valid until it expires.

The algorithm confusion attack: always specify alg

The single most dangerous JWT vulnerability is the algorithm confusion attack. The JWT spec includes alg: none — a token with no signature that some libraries accept as valid by default. Additionally, some libraries can be confused into using the public key as an HMAC secret for RS256 tokens. Both attacks allow forging tokens without knowing any secrets.

import jwt from 'jsonwebtoken';

// ✗ Vulnerable — accepts any algorithm including "none"
jwt.verify(token, secret);

// ✓ Safe — explicitly whitelist allowed algorithms
jwt.verify(token, secret, { algorithms: ['HS256'] });

// For RS256 (asymmetric):
jwt.verify(token, publicKey, { algorithms: ['RS256'] });

// Never do this:
const decoded = jwt.decode(token); // Does NOT verify signature!

HS256 vs RS256: which to use

HS256 (HMAC-SHA256) — Uses a shared secret for both signing and verification. Simple and fast. Use when a single service signs and verifies tokens.

RS256 (RSA-SHA256) — Uses a private key to sign, public key to verify. The private key never leaves your auth service. Other services can verify tokens using the public key without access to the private key. Use in distributed systems where multiple services need to verify tokens independently.

// Generating keys for RS256:
// openssl genrsa -out private.pem 2048
// openssl rsa -in private.pem -pubout -out public.pem

// Signing (auth service only — has private key):
const token = jwt.sign({ userId, role }, privateKey, {
  algorithm: 'RS256',
  expiresIn: '15m',
  issuer: 'api.myapp.com',
  audience: 'myapp-clients',
});

// Verifying (any service — only needs public key):
jwt.verify(token, publicKey, {
  algorithms: ['RS256'],
  issuer: 'api.myapp.com',
  audience: 'myapp-clients',
});

Where to store tokens: the definitive answer

This is the most debated JWT question, and the right answer depends on your threat model:

localStorage / sessionStorage — Accessible to any JavaScript on the page. One XSS vulnerability (in your code, a dependency, or a third-party script) exfiltrates all users' tokens. Avoid for tokens with any meaningful lifetime.

httpOnly cookies — Inaccessible to JavaScript. Immune to XSS. Vulnerable to CSRF (a malicious site can make requests that include your cookies). Mitigated with SameSite=Strict. This is the recommended approach for web apps where CSRF is manageable.

In-memory (JavaScript variable) — Never touches storage. Cleared on tab close or refresh. Requires a silent refresh mechanism. Most secure against XSS and CSRF but requires more implementation effort.

The access + refresh token pattern

Short-lived access tokens minimize the blast radius of token theft. Long-lived refresh tokens stored in httpOnly cookies enable persistent sessions without constant re-authentication.

import jwt from 'jsonwebtoken';
import crypto from 'crypto';

interface TokenPair {
  accessToken: string;
  refreshToken: string;
}

function issueTokens(userId: string, role: string): TokenPair {
  const jti = crypto.randomUUID(); // Unique token ID for revocation

  const accessToken = jwt.sign(
    { userId, role, jti },
    process.env.JWT_ACCESS_SECRET!,
    { expiresIn: '15m', algorithm: 'HS256' }
  );

  const refreshToken = jwt.sign(
    { userId, jti: crypto.randomUUID() },
    process.env.JWT_REFRESH_SECRET!,
    { expiresIn: '7d', algorithm: 'HS256' }
  );

  return { accessToken, refreshToken };
}

// Login endpoint
app.post('/auth/login', async (req, res) => {
  const user = await validateCredentials(req.body.email, req.body.password);
  if (!user) return res.status(401).json({ error: 'Invalid credentials' });

  const { accessToken, refreshToken } = issueTokens(user.id, user.role);

  // Refresh token in httpOnly cookie — inaccessible to JavaScript
  res.cookie('refreshToken', refreshToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
    path: '/auth/refresh',            // Only sent to refresh endpoint
  });

  // Access token in response body — stored in memory by the client
  res.json({ accessToken, expiresIn: 900 });
});

Refresh token rotation

When a refresh token is used, issue a new one and invalidate the old one. This limits the window of a stolen refresh token to a single use.

app.post('/auth/refresh', async (req, res) => {
  const { refreshToken } = req.cookies;
  if (!refreshToken) return res.status(401).json({ error: 'No refresh token' });

  let decoded: any;
  try {
    decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET!, {
      algorithms: ['HS256'],
    });
  } catch {
    return res.status(401).json({ error: 'Invalid refresh token' });
  }

  // Check if this refresh token has been revoked
  const revoked = await redis.get(`revoked:refresh:${decoded.jti}`);
  if (revoked) return res.status(401).json({ error: 'Refresh token revoked' });

  // Rotate: revoke old, issue new
  await redis.setex(`revoked:refresh:${decoded.jti}`, 7 * 86400, '1');
  const { accessToken, refreshToken: newRefreshToken } = issueTokens(decoded.userId, decoded.role);

  res.cookie('refreshToken', newRefreshToken, {
    httpOnly: true, secure: true, sameSite: 'strict',
    maxAge: 7 * 24 * 60 * 60 * 1000, path: '/auth/refresh',
  });

  res.json({ accessToken, expiresIn: 900 });
});

Token revocation with a blocklist

JWTs are stateless — once issued, they're valid until expiry. To revoke a specific access token (on logout, password change, or suspicious activity), maintain a blocklist in Redis keyed by the token's JTI (JWT ID). The TTL should match the token's remaining lifetime.

// Add JTI claim when issuing
const jti = crypto.randomUUID();
const accessToken = jwt.sign({ userId, role, jti }, secret, { expiresIn: '15m' });

// On logout — add to blocklist with TTL matching token lifetime
app.post('/auth/logout', requireAuth, async (req, res) => {
  const { jti, exp } = req.user; // Attached by auth middleware
  const ttl = exp - Math.floor(Date.now() / 1000);

  if (ttl > 0) {
    await redis.setex(`blocklist:access:${jti}`, ttl, '1');
  }

  // Clear refresh token cookie
  res.clearCookie('refreshToken', { path: '/auth/refresh' });
  res.json({ ok: true });
});

// Auth middleware — check blocklist on every request
export async function requireAuth(req, res, next) {
  const token = req.headers.authorization?.replace('Bearer ', '');
  if (!token) return res.status(401).json({ error: 'No token' });

  let decoded: any;
  try {
    decoded = jwt.verify(token, secret, { algorithms: ['HS256'] });
  } catch {
    return res.status(401).json({ error: 'Invalid token' });
  }

  // Check blocklist (only if token has a jti)
  if (decoded.jti) {
    const blocked = await redis.get(`blocklist:access:${decoded.jti}`);
    if (blocked) return res.status(401).json({ error: 'Token revoked' });
  }

  req.user = decoded;
  next();
}

Payload design: what to include (and what not to)

The JWT payload is sent with every request and decoded by every service that validates it. Keep it small and include only authorization-relevant data.

// ✓ Good payload — minimal, authorization-relevant
{
  "userId": "usr_abc123",
  "orgId": "org_xyz789",
  "role": "admin",
  "jti": "550e8400-e29b-41d4-a716-446655440000",
  "iat": 1718899200,
  "exp": 1718900100
}

// ✗ Bad payload — too much data, includes sensitive info
{
  "userId": "usr_abc123",
  "email": "[email protected]",   // Changes are not reflected until new token
  "password": "hashed_pw",       // Never — ever — include this
  "billingInfo": { ... },        // PII — don't include
  "preferences": { ... },        // Not needed for auth — put in DB
  "permissions": ["read:all", "write:all", "delete:all", ...] // Could be stale
}

Secret key requirements and rotation

For HS256, your secret must be at minimum 256 bits (32 bytes) of random entropy. A short, guessable, or reused secret makes the signature breakable via dictionary attack.

# Generate a strong secret (run once, store in secrets manager):
openssl rand -hex 32
# → a8f3d1e9c7b2a4f6d8e0c2b4a6d8e0c2b4a6d8e0c2b4a6d8e0c2b4a6d8e0c2

# In your app:
if (!process.env.JWT_ACCESS_SECRET || process.env.JWT_ACCESS_SECRET.length < 32) {
  throw new Error('JWT_ACCESS_SECRET must be at least 32 characters');
}

Rotate secrets periodically and always rotate immediately after a suspected compromise. For HS256, this invalidates all existing tokens — plan for a graceful rotation period where both old and new secrets are accepted.

Common vulnerabilities checklist

  • alg: none attack — Always specify algorithms in verify options
  • Weak secret — Use at least 256 bits of random entropy
  • No expiry — Always set expiresIn
  • Token stored in localStorage — Use httpOnly cookies or in-memory
  • No revocation on logout — Implement JTI blocklist or short token lifetime
  • Sensitive data in payload — Payload is readable by anyone with the token
  • Not validating issuer/audience — A token from your staging environment should not be valid in production

Ready to put this into practice?

Deploy your Node.js app to production in minutes — zero YAML, automatic CI/CD, and HTTPS included.