How to Implement JWT Security

Share
How-To Guide

How to Implement JWT Security

Build secure token-based authentication without the pitfalls

TL;DR

TL;DR (30 minutes): Use RS256 algorithm with strong keys, short expiration (15 min access, 7 day refresh), store tokens in httpOnly cookies (not localStorage), implement token rotation on refresh, validate all claims (iss, aud, exp), and maintain a token blacklist for revocation.

Prerequisites:

  • Understanding of authentication concepts
  • Node.js or similar backend
  • A way to generate/store secrets or key pairs

Why This Matters

JWTs are commonly used but frequently misconfigured. The "none" algorithm attack, weak secrets, storing tokens in localStorage, and missing validation have led to countless security breaches. This guide covers secure JWT implementation.

Step-by-Step Guide

1

Choose the right signing algorithm

// AVOID: HS256 with weak secrets
// AVOID: The "none" algorithm
// GOOD: RS256 (RSA) or ES256 (ECDSA) for production

import { generateKeyPairSync } from 'crypto';

// Generate RSA key pair (do this once, store securely)
const { publicKey, privateKey } = generateKeyPairSync('rsa', {
  modulusLength: 2048,
  publicKeyEncoding: { type: 'spki', format: 'pem' },
  privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
});

// Or for ECDSA (smaller tokens)
const { publicKey: ecPublicKey, privateKey: ecPrivateKey } =
  generateKeyPairSync('ec', {
    namedCurve: 'P-256',
    publicKeyEncoding: { type: 'spki', format: 'pem' },
    privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
  });

// Store keys in environment variables or secrets manager
// Never commit private keys to git!
2

Create tokens with proper claims

import jwt from 'jsonwebtoken';

const ACCESS_TOKEN_EXPIRY = '15m';  // Short-lived
const REFRESH_TOKEN_EXPIRY = '7d';   // Longer-lived

function createTokens(user) {
  const accessToken = jwt.sign(
    {
      sub: user.id,              // Subject (user ID)
      email: user.email,
      role: user.role
    },
    process.env.JWT_PRIVATE_KEY,
    {
      algorithm: 'RS256',
      expiresIn: ACCESS_TOKEN_EXPIRY,
      issuer: 'https://myapp.com',    // Who issued the token
      audience: 'https://myapp.com',   // Who can use it
      jwtid: crypto.randomUUID()       // Unique token ID
    }
  );

  const refreshToken = jwt.sign(
    {
      sub: user.id,
      type: 'refresh'
    },
    process.env.JWT_PRIVATE_KEY,
    {
      algorithm: 'RS256',
      expiresIn: REFRESH_TOKEN_EXPIRY,
      issuer: 'https://myapp.com',
      jwtid: crypto.randomUUID()
    }
  );

  // Store refresh token hash in database for revocation
  await db.refreshToken.create({
    data: {
      userId: user.id,
      tokenHash: hashToken(refreshToken),
      expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
    }
  });

  return { accessToken, refreshToken };
}
3

Validate tokens properly

import jwt from 'jsonwebtoken';

async function validateAccessToken(token) {
  try {
    const decoded = jwt.verify(token, process.env.JWT_PUBLIC_KEY, {
      algorithms: ['RS256'],        // CRITICAL: Specify allowed algorithms
      issuer: 'https://myapp.com',  // Verify issuer
      audience: 'https://myapp.com', // Verify audience
      complete: true                 // Get header info too
    });

    // Check if token is blacklisted (for revocation)
    const isBlacklisted = await db.blacklistedToken.findUnique({
      where: { jti: decoded.payload.jti }
    });

    if (isBlacklisted) {
      throw new Error('Token has been revoked');
    }

    return decoded.payload;
  } catch (error) {
    if (error.name === 'TokenExpiredError') {
      throw new Error('Token expired');
    }
    if (error.name === 'JsonWebTokenError') {
      throw new Error('Invalid token');
    }
    throw error;
  }
}

// Middleware
async function authMiddleware(req, res, next) {
  const token = req.cookies.accessToken;  // From httpOnly cookie

  if (!token) {
    return res.status(401).json({ error: 'No token provided' });
  }

  try {
    const user = await validateAccessToken(token);
    req.user = user;
    next();
  } catch (error) {
    return res.status(401).json({ error: error.message });
  }
}
4

Store tokens securely

// WRONG: localStorage is vulnerable to XSS
localStorage.setItem('token', accessToken);

// CORRECT: Use httpOnly cookies (server sets them)
function setAuthCookies(res, accessToken, refreshToken) {
  // Access token cookie
  res.cookie('accessToken', accessToken, {
    httpOnly: true,     // Can't be accessed by JavaScript
    secure: true,       // HTTPS only
    sameSite: 'strict', // Prevent CSRF
    maxAge: 15 * 60 * 1000,  // 15 minutes
    path: '/'
  });

  // Refresh token cookie
  res.cookie('refreshToken', refreshToken, {
    httpOnly: true,
    secure: true,
    sameSite: 'strict',
    maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
    path: '/api/auth/refresh'  // Only sent to refresh endpoint
  });
}

// Clear cookies on logout
function clearAuthCookies(res) {
  res.clearCookie('accessToken');
  res.clearCookie('refreshToken', { path: '/api/auth/refresh' });
}
5

Implement refresh token rotation

async function refreshAccessToken(req, res) {
  const refreshToken = req.cookies.refreshToken;

  if (!refreshToken) {
    return res.status(401).json({ error: 'No refresh token' });
  }

  try {
    // Verify refresh token
    const decoded = jwt.verify(refreshToken, process.env.JWT_PUBLIC_KEY, {
      algorithms: ['RS256'],
      issuer: 'https://myapp.com'
    });

    // Check if refresh token exists in database
    const storedToken = await db.refreshToken.findFirst({
      where: {
        userId: decoded.sub,
        tokenHash: hashToken(refreshToken),
        expiresAt: { gt: new Date() }
      }
    });

    if (!storedToken) {
      // Token reuse detected! Revoke all user's tokens
      await db.refreshToken.deleteMany({
        where: { userId: decoded.sub }
      });
      clearAuthCookies(res);
      return res.status(401).json({
        error: 'Invalid refresh token. Please login again.'
      });
    }

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

    // Get user and create new tokens
    const user = await db.user.findUnique({
      where: { id: decoded.sub }
    });

    const { accessToken, refreshToken: newRefreshToken } = createTokens(user);
    setAuthCookies(res, accessToken, newRefreshToken);

    return res.json({ success: true });
  } catch (error) {
    clearAuthCookies(res);
    return res.status(401).json({ error: 'Invalid refresh token' });
  }
}
6

Handle token revocation

// For immediate revocation (logout, password change, etc.)
async function revokeAllUserTokens(userId) {
  // Delete all refresh tokens
  await db.refreshToken.deleteMany({
    where: { userId }
  });

  // For access tokens, we need a blacklist (they're stateless)
  // Option 1: Blacklist specific tokens until they expire
  // Option 2: Track a "tokens valid after" timestamp per user
}

// Logout endpoint
async function logout(req, res) {
  const accessToken = req.cookies.accessToken;
  const refreshToken = req.cookies.refreshToken;

  if (accessToken) {
    // Blacklist access token until expiry
    const decoded = jwt.decode(accessToken);
    if (decoded?.jti && decoded?.exp) {
      await db.blacklistedToken.create({
        data: {
          jti: decoded.jti,
          expiresAt: new Date(decoded.exp * 1000)
        }
      });
    }
  }

  if (refreshToken) {
    // Delete refresh token from database
    await db.refreshToken.deleteMany({
      where: { tokenHash: hashToken(refreshToken) }
    });
  }

  clearAuthCookies(res);
  return res.json({ success: true });
}

// Clean up expired blacklist entries (run periodically)
async function cleanupBlacklist() {
  await db.blacklistedToken.deleteMany({
    where: { expiresAt: { lt: new Date() } }
  });
}

JWT Security Mistakes to Avoid:

  • Never allow the "none" algorithm - always specify allowed algorithms
  • Never store JWTs in localStorage - use httpOnly cookies
  • Never use weak secrets for HS256 - minimum 256 bits of entropy
  • Never put sensitive data in JWT payload - it's base64, not encrypted
  • Never skip validation - always verify signature, exp, iss, aud
  • Never use long-lived tokens without refresh - max 15-30 minutes

How to Verify It Worked

  1. Test algorithm confusion: Send a token with alg="none" - should be rejected
  2. Test expired tokens: Wait for expiry, token should be rejected
  3. Test refresh rotation: Use a refresh token twice - second should fail
  4. Test revocation: Logout, then try the old token - should fail

Common Errors & Troubleshooting

Error: "invalid algorithm"

You're verifying with a different algorithm than used for signing. Check that algorithms array matches.

Error: "invalid signature"

Key mismatch. For RS256, sign with private key, verify with public key.

Tokens not being sent

Check cookie settings - sameSite, secure, and path must be correct for your setup.

Token too large

JWTs in cookies have ~4KB limit. Don't put large data in tokens - use user ID and fetch data as needed.

Why not just use sessions instead?

Sessions are often simpler and more secure for traditional web apps. JWTs make sense for: stateless microservices, mobile apps, or when you need tokens to work across domains. For most web apps, sessions are fine.

What's the deal with HS256 vs RS256?

HS256 uses a shared secret (both sides need the same key). RS256 uses public/private keys (sign with private, verify with public). RS256 is better when you have multiple services - they can verify tokens without having the signing key.

How do I handle token refresh in a SPA?

The access token cookie is sent automatically. When you get a 401, call your /refresh endpoint. If that fails, redirect to login. Use an interceptor in your HTTP client to handle this automatically.

Related guides:Session Management · Secure Login Form · OAuth Setup

How-To Guides

How to Implement JWT Security