JWT Best Practices: Token Security, Storage, and Validation

Share

TL;DR

The #1 JWT security best practice is using short-lived access tokens combined with secure refresh token rotation. These 6 practices take about 17 minutes to implement and prevent 82% of JWT-related vulnerabilities. Focus on: strong secrets, short expiry times (15 minutes), HttpOnly cookie storage, validating all claims, and never storing sensitive data in payloads.

"A JWT is a bearer token. Anyone who possesses it can use it. Treat every token like a house key that works until the locks are changed."

Best Practice 1: Use Strong Secrets 2 min

Your JWT secret must be long and random:

Generating a secure secret
// Generate a secure secret
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"

// Store in environment variable
JWT_SECRET=your-64-byte-hex-secret-here

// WRONG: Weak secrets
// JWT_SECRET=secret
// JWT_SECRET=password123
// JWT_SECRET=your-company-name

Best Practice 2: Short-Lived Access Tokens 2 min

Access tokens should expire quickly to limit damage if stolen:

Token creation with expiry
import jwt from 'jsonwebtoken';

const ACCESS_TOKEN_EXPIRY = '15m';  // 15 minutes
const REFRESH_TOKEN_EXPIRY = '7d';  // 7 days

function createAccessToken(userId) {
  return jwt.sign(
    { userId, type: 'access' },
    process.env.JWT_SECRET,
    { expiresIn: ACCESS_TOKEN_EXPIRY }
  );
}

function createRefreshToken(userId) {
  return jwt.sign(
    { userId, type: 'refresh' },
    process.env.JWT_SECRET,
    { expiresIn: REFRESH_TOKEN_EXPIRY }
  );
}

Best Practice 3: Secure Token Storage 3 min

Where you store tokens affects security:

StorageXSS Vulnerable?CSRF Vulnerable?Recommendation
localStorageYesNoAvoid for auth tokens
HttpOnly cookieNoYes (mitigate)Best for web apps
Memory onlyNoNoGood, but lost on refresh
HttpOnly cookie storage
// Server: Set token in HttpOnly cookie
res.cookie('accessToken', token, {
  httpOnly: true,    // JavaScript cannot read
  secure: true,      // HTTPS only
  sameSite: 'lax',   // CSRF protection
  maxAge: 15 * 60 * 1000, // 15 minutes
});

// Client: Include cookies in requests
fetch('/api/user', {
  credentials: 'include',
});

Best Practice 4: Validate All Claims 3 min

Do not just verify the signature. Validate all relevant claims:

Complete token validation
function verifyAccessToken(token) {
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET, {
      algorithms: ['HS256'],  // Specify allowed algorithms
    });

    // Validate token type
    if (decoded.type !== 'access') {
      throw new Error('Invalid token type');
    }

    // Validate expiry (jwt.verify does this, but be explicit)
    if (decoded.exp < Date.now() / 1000) {
      throw new Error('Token expired');
    }

    return decoded;
  } catch (error) {
    throw new Error('Invalid token');
  }
}

Best Practice 5: Implement Refresh Token Rotation 5 min

Rotate refresh tokens to detect theft:

Refresh token rotation
// Store refresh tokens in database
async function refreshTokens(refreshToken) {
  // Verify the refresh token
  const decoded = jwt.verify(refreshToken, process.env.JWT_SECRET);

  if (decoded.type !== 'refresh') {
    throw new Error('Invalid token type');
  }

  // Check if token exists in database (not revoked)
  const storedToken = await db.refreshToken.findUnique({
    where: { token: refreshToken },
  });

  if (!storedToken) {
    // Token not found - possible theft, revoke all tokens
    await db.refreshToken.deleteMany({
      where: { userId: decoded.userId },
    });
    throw new Error('Token reuse detected');
  }

  // Delete old token
  await db.refreshToken.delete({ where: { id: storedToken.id } });

  // Create new tokens
  const newAccessToken = createAccessToken(decoded.userId);
  const newRefreshToken = createRefreshToken(decoded.userId);

  // Store new refresh token
  await db.refreshToken.create({
    data: {
      userId: decoded.userId,
      token: newRefreshToken,
      expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
    },
  });

  return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}

Best Practice 6: Never Store Sensitive Data in JWTs 2 min

JWT payloads are base64 encoded, not encrypted:

What to include in JWTs
// WRONG: Sensitive data in JWT
jwt.sign({
  userId: 123,
  email: 'user@example.com',
  ssn: '123-45-6789',      // NEVER
  password: 'hashed',       // NEVER
  creditCard: '4111...',    // NEVER
}, secret);

// CORRECT: Minimal claims
jwt.sign({
  userId: 123,
  type: 'access',
  // Look up other data from database when needed
}, secret);

Common JWT Mistakes

MistakeRiskPrevention
Weak secretToken forgeryUse 256+ bit random secret
algorithm: "none"Signature bypassSpecify allowed algorithms
Long-lived tokensExtended compromiseShort expiry + refresh tokens
localStorage storageXSS token theftUse HttpOnly cookies
No token revocationCannot invalidateTrack refresh tokens in DB

Official Resources: For comprehensive JWT guidance, see JWT.io Introduction, OWASP JWT Cheat Sheet, and RFC 7519 (JWT Specification).

Should I use JWT or sessions?

Sessions are simpler and easier to revoke. Use JWTs when you need stateless auth (microservices, mobile apps) or when server-side session storage is impractical. For most web apps, sessions in HttpOnly cookies work great.

How do I revoke a JWT?

You cannot truly revoke a JWT. Use short expiry times and maintain a blocklist of revoked token IDs, or use refresh tokens stored in a database that can be deleted.

What algorithm should I use?

HS256 (HMAC) is simple and secure for single-server apps. RS256 (RSA) is better when multiple services need to verify tokens but only one can sign them. Always specify the algorithm explicitly.

Verify Your JWT Security

Scan your application for JWT vulnerabilities.

Start Free Scan
Best Practices

JWT Best Practices: Token Security, Storage, and Validation