JWT Authentication in Node.js: Complete Security Guide

Implement secure JWT authentication in Node.js. Learn access tokens, refresh tokens, and security best practices.

Mahmoud DEVO
Mahmoud DEVO
November 25, 2024 15 min read
JWT Authentication in Node.js: Complete Security Guide

JSON Web Tokens (JWT) are the standard for stateless authentication in modern web applications. But implementing JWT correctly requires understanding security implications. Let’s build a secure authentication system from scratch.

How JWT Works

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                         JWT Structure                        β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚  Header          β”‚  Payload           β”‚  Signature          β”‚
β”‚  ─────────────   β”‚  ────────────────  β”‚  ───────────────    β”‚
β”‚  {               β”‚  {                 β”‚  HMACSHA256(        β”‚
β”‚    "alg":"HS256" β”‚    "sub": "123",   β”‚    base64(header) + β”‚
β”‚    "typ":"JWT"   β”‚    "name": "John", β”‚    "." +            β”‚
β”‚  }               β”‚    "iat": 123456,  β”‚    base64(payload), β”‚
β”‚                  β”‚    "exp": 123556   β”‚    secret           β”‚
β”‚                  β”‚  }                 β”‚  )                  β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚  eyJhbGciOiJIUzI1N.eyJzdWIiOiIxMjM0NTY.SflKxwRJSMeKKF2QT4   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Project Setup

npm install express jsonwebtoken bcrypt cookie-parser
npm install -D typescript @types/node @types/express @types/jsonwebtoken @types/bcrypt

Token Types: Access vs Refresh

Token TypeLifespanStoragePurpose
Access Token15 minMemory/HeaderAPI authorization
Refresh Token7 daysHttpOnly CookieGet new access token
// config/jwt.ts
export const jwtConfig = {
  accessToken: {
    secret: process.env.JWT_ACCESS_SECRET!,
    expiresIn: '15m',
  },
  refreshToken: {
    secret: process.env.JWT_REFRESH_SECRET!,
    expiresIn: '7d',
  },
};

Token Generation

// lib/tokens.ts
import jwt from 'jsonwebtoken';
import { jwtConfig } from '../config/jwt';

interface TokenPayload {
  userId: string;
  email: string;
  role: string;
}

export function generateAccessToken(payload: TokenPayload): string {
  return jwt.sign(payload, jwtConfig.accessToken.secret, {
    expiresIn: jwtConfig.accessToken.expiresIn,
    issuer: 'your-app',
    audience: 'your-app-users',
  });
}

export function generateRefreshToken(payload: TokenPayload): string {
  return jwt.sign(
    { userId: payload.userId }, // Minimal payload for refresh
    jwtConfig.refreshToken.secret,
    {
      expiresIn: jwtConfig.refreshToken.expiresIn,
      issuer: 'your-app',
    }
  );
}

export function verifyAccessToken(token: string): TokenPayload {
  return jwt.verify(token, jwtConfig.accessToken.secret, {
    issuer: 'your-app',
    audience: 'your-app-users',
  }) as TokenPayload;
}

export function verifyRefreshToken(token: string): { userId: string } {
  return jwt.verify(token, jwtConfig.refreshToken.secret, {
    issuer: 'your-app',
  }) as { userId: string };
}

export function generateTokenPair(payload: TokenPayload) {
  return {
    accessToken: generateAccessToken(payload),
    refreshToken: generateRefreshToken(payload),
  };
}

Refresh Token Storage

For production, store refresh tokens in the database to enable revocation:

// models/refreshToken.ts
interface RefreshToken {
  id: string;
  token: string;
  userId: string;
  expiresAt: Date;
  createdAt: Date;
  revokedAt: Date | null;
  replacedByToken: string | null;
}

// lib/refreshTokenStore.ts
import { db } from './database';
import crypto from 'crypto';

export async function createRefreshToken(userId: string): Promise<string> {
  const token = crypto.randomBytes(40).toString('hex');
  const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days

  await db.refreshTokens.create({
    token: hashToken(token),
    userId,
    expiresAt,
  });

  return token;
}

export async function validateRefreshToken(token: string): Promise<RefreshToken | null> {
  const hashedToken = hashToken(token);

  const storedToken = await db.refreshTokens.findOne({
    where: {
      token: hashedToken,
      revokedAt: null,
      expiresAt: { gt: new Date() },
    },
  });

  return storedToken;
}

export async function revokeRefreshToken(token: string): Promise<void> {
  const hashedToken = hashToken(token);

  await db.refreshTokens.update({
    where: { token: hashedToken },
    data: { revokedAt: new Date() },
  });
}

export async function rotateRefreshToken(
  oldToken: string,
  userId: string
): Promise<string> {
  const newToken = await createRefreshToken(userId);

  // Mark old token as replaced
  await db.refreshTokens.update({
    where: { token: hashToken(oldToken) },
    data: {
      revokedAt: new Date(),
      replacedByToken: hashToken(newToken),
    },
  });

  return newToken;
}

// Revoke all tokens for a user (logout everywhere)
export async function revokeAllUserTokens(userId: string): Promise<void> {
  await db.refreshTokens.updateMany({
    where: { userId, revokedAt: null },
    data: { revokedAt: new Date() },
  });
}

function hashToken(token: string): string {
  return crypto.createHash('sha256').update(token).digest('hex');
}

Authentication Controller

// controllers/auth.ts
import { Request, Response } from 'express';
import bcrypt from 'bcrypt';
import { db } from '../lib/database';
import {
  generateAccessToken,
  verifyRefreshToken,
} from '../lib/tokens';
import {
  createRefreshToken,
  validateRefreshToken,
  rotateRefreshToken,
  revokeRefreshToken,
  revokeAllUserTokens,
} from '../lib/refreshTokenStore';

const COOKIE_OPTIONS = {
  httpOnly: true,
  secure: process.env.NODE_ENV === 'production',
  sameSite: 'strict' as const,
  maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
  path: '/api/auth',
};

export async function register(req: Request, res: Response) {
  const { email, password, name } = req.body;

  // Check existing user
  const existing = await db.users.findByEmail(email);
  if (existing) {
    return res.status(409).json({ error: 'Email already registered' });
  }

  // Hash password
  const hashedPassword = await bcrypt.hash(password, 12);

  // Create user
  const user = await db.users.create({
    email,
    password: hashedPassword,
    name,
  });

  // Generate tokens
  const payload = { userId: user.id, email: user.email, role: user.role };
  const accessToken = generateAccessToken(payload);
  const refreshToken = await createRefreshToken(user.id);

  // Set refresh token in cookie
  res.cookie('refreshToken', refreshToken, COOKIE_OPTIONS);

  res.status(201).json({
    user: { id: user.id, email: user.email, name: user.name },
    accessToken,
  });
}

export async function login(req: Request, res: Response) {
  const { email, password } = req.body;

  // Find user
  const user = await db.users.findByEmail(email);
  if (!user) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  // Verify password
  const isValid = await bcrypt.compare(password, user.password);
  if (!isValid) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  // Generate tokens
  const payload = { userId: user.id, email: user.email, role: user.role };
  const accessToken = generateAccessToken(payload);
  const refreshToken = await createRefreshToken(user.id);

  // Set refresh token in cookie
  res.cookie('refreshToken', refreshToken, COOKIE_OPTIONS);

  res.json({
    user: { id: user.id, email: user.email, name: user.name },
    accessToken,
  });
}

export async function refresh(req: Request, res: Response) {
  const refreshToken = req.cookies.refreshToken;

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

  // Validate stored token
  const storedToken = await validateRefreshToken(refreshToken);
  if (!storedToken) {
    // Token reuse detected - revoke all user tokens
    try {
      const decoded = verifyRefreshToken(refreshToken);
      await revokeAllUserTokens(decoded.userId);
    } catch {
      // Token is invalid
    }
    res.clearCookie('refreshToken', { path: '/api/auth' });
    return res.status(401).json({ error: 'Invalid refresh token' });
  }

  // Get user
  const user = await db.users.findById(storedToken.userId);
  if (!user) {
    return res.status(401).json({ error: 'User not found' });
  }

  // Rotate tokens
  const newRefreshToken = await rotateRefreshToken(refreshToken, user.id);
  const payload = { userId: user.id, email: user.email, role: user.role };
  const accessToken = generateAccessToken(payload);

  res.cookie('refreshToken', newRefreshToken, COOKIE_OPTIONS);

  res.json({ accessToken });
}

export async function logout(req: Request, res: Response) {
  const refreshToken = req.cookies.refreshToken;

  if (refreshToken) {
    await revokeRefreshToken(refreshToken);
  }

  res.clearCookie('refreshToken', { path: '/api/auth' });
  res.json({ message: 'Logged out successfully' });
}

export async function logoutAll(req: Request, res: Response) {
  const userId = req.user!.userId;

  await revokeAllUserTokens(userId);

  res.clearCookie('refreshToken', { path: '/api/auth' });
  res.json({ message: 'Logged out from all devices' });
}

Authentication Middleware

// middleware/auth.ts
import { Request, Response, NextFunction } from 'express';
import { verifyAccessToken } from '../lib/tokens';

declare global {
  namespace Express {
    interface Request {
      user?: {
        userId: string;
        email: string;
        role: string;
      };
    }
  }
}

export function authenticate(req: Request, res: Response, next: NextFunction) {
  const authHeader = req.headers.authorization;

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

  const token = authHeader.slice(7);

  try {
    const payload = verifyAccessToken(token);
    req.user = payload;
    next();
  } catch (error) {
    if (error instanceof jwt.TokenExpiredError) {
      return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
    }
    return res.status(401).json({ error: 'Invalid token' });
  }
}

export function requireRole(...roles: string[]) {
  return (req: Request, res: Response, next: NextFunction) => {
    if (!req.user) {
      return res.status(401).json({ error: 'Not authenticated' });
    }

    if (!roles.includes(req.user.role)) {
      return res.status(403).json({ error: 'Insufficient permissions' });
    }

    next();
  };
}

Frontend Integration

// lib/api.ts
class ApiClient {
  private accessToken: string | null = null;
  private refreshPromise: Promise<void> | null = null;

  setAccessToken(token: string) {
    this.accessToken = token;
  }

  async request<T>(url: string, options: RequestInit = {}): Promise<T> {
    const response = await this.fetchWithAuth(url, options);

    if (response.status === 401) {
      const data = await response.json();

      if (data.code === 'TOKEN_EXPIRED') {
        // Token expired - try to refresh
        await this.refreshToken();
        // Retry original request
        return this.request(url, options);
      }

      // Other auth error - logout
      this.logout();
      throw new Error('Authentication failed');
    }

    if (!response.ok) {
      throw new Error(`Request failed: ${response.status}`);
    }

    return response.json();
  }

  private async fetchWithAuth(url: string, options: RequestInit): Promise<Response> {
    const headers = new Headers(options.headers);

    if (this.accessToken) {
      headers.set('Authorization', `Bearer ${this.accessToken}`);
    }

    return fetch(url, { ...options, headers, credentials: 'include' });
  }

  private async refreshToken(): Promise<void> {
    // Prevent multiple simultaneous refresh requests
    if (this.refreshPromise) {
      return this.refreshPromise;
    }

    this.refreshPromise = (async () => {
      try {
        const response = await fetch('/api/auth/refresh', {
          method: 'POST',
          credentials: 'include',
        });

        if (!response.ok) {
          throw new Error('Refresh failed');
        }

        const { accessToken } = await response.json();
        this.accessToken = accessToken;
      } finally {
        this.refreshPromise = null;
      }
    })();

    return this.refreshPromise;
  }

  async login(email: string, password: string): Promise<User> {
    const response = await fetch('/api/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password }),
      credentials: 'include',
    });

    if (!response.ok) {
      throw new Error('Login failed');
    }

    const { user, accessToken } = await response.json();
    this.accessToken = accessToken;
    return user;
  }

  async logout(): Promise<void> {
    await fetch('/api/auth/logout', {
      method: 'POST',
      credentials: 'include',
    });
    this.accessToken = null;
  }
}

export const api = new ApiClient();

Security Best Practices

1. Use Strong Secrets

# Generate secure secrets
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"

2. Implement Rate Limiting

import rateLimit from 'express-rate-limit';

const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 5, // 5 attempts
  message: { error: 'Too many login attempts, please try again later' },
});

app.use('/api/auth/login', authLimiter);

3. Add CSRF Protection

import csrf from 'csurf';

const csrfProtection = csrf({ cookie: true });

// Apply to state-changing auth routes
app.post('/api/auth/logout', csrfProtection, logoutHandler);
const COOKIE_OPTIONS = {
  httpOnly: true,         // Prevent XSS access
  secure: true,           // HTTPS only in production
  sameSite: 'strict',     // Prevent CSRF
  path: '/api/auth',      // Limit cookie scope
  maxAge: 7 * 24 * 60 * 60 * 1000,
};

Conclusion

Secure JWT authentication requires:

  • Short-lived access tokens: 15 minutes or less
  • Refresh token rotation: New token on each use
  • Secure storage: HttpOnly cookies for refresh tokens
  • Token revocation: Database-backed invalidation
  • Rate limiting: Prevent brute force attacks

Implement these patterns and your authentication will be production-ready.


Questions about JWT security? Ask in the comments!

Advertisement

In-Article Ad

Dev Mode

Share this article

Mahmoud DEVO

Mahmoud DEVO

Senior Full-Stack Developer

I'm a passionate full-stack developer with 10+ years of experience building scalable web applications. I write about Vue.js, Node.js, PostgreSQL, and modern DevOps practices.

Enjoyed this article?

Subscribe to get more tech content delivered to your inbox.

Related Articles