Security February 20, 2024 9 min read

Securing Your Node.js Applications

Essential security practices for Node.js applications. Learn about authentication, authorization, data validation, and common vulnerabilities.

Nikesh Bhattarai
Nikesh Bhattarai
Backend Developer & AI/ML Engineer
Node.js Security

Introduction

Security is a critical aspect of web development that cannot be overlooked. Node.js applications face numerous security threats, from injection attacks to cross-site scripting. This comprehensive guide covers essential security practices to protect your Node.js applications from common vulnerabilities.

We'll explore authentication mechanisms, authorization patterns, input validation techniques, and security best practices that will help you build robust, secure applications that protect user data and maintain system integrity.

Common Security Threats

Understanding common security threats is the first step in protecting your applications:

// 1. SQL Injection
// Vulnerable code
app.get('/users/:id', (req, res) => {
  const query = `SELECT * FROM users WHERE id = ${req.params.id}`;
  db.query(query, (err, results) => {
    res.json(results);
  });
});

// Secure code with parameterized queries
app.get('/users/:id', (req, res) => {
  const query = 'SELECT * FROM users WHERE id = ?';
  db.query(query, [req.params.id], (err, results) => {
    res.json(results);
  });
});

// 2. Cross-Site Scripting (XSS)
// Vulnerable code
app.get('/search', (req, res) => {
  const term = req.query.term;
  res.send(`

Search Results for: ${term}

`); }); // Secure code with output encoding app.get('/search', (req, res) => { const term = escapeHtml(req.query.term); res.send(`

Search Results for: ${term}

); }); // 3. NoSQL Injection // Vulnerable code app.post('/login', (req, res) => { const { username, password } = req.body; User.findOne({ username, password }, (err, user) => { res.json(user); }); }); // Secure code with input validation app.post('/login', async (req, res) => { const { username, password } = req.body; // Validate input format if (typeof username !== 'string' || typeof password !== 'string') { return res.status(400).json({ error: 'Invalid input' }); } const user = await User.findOne({ username }); if (user && await bcrypt.compare(password, user.password)) { res.json(user); } else { res.status(401).json({ error: 'Invalid credentials' }); } });

Authentication Best Practices

Password Security

Implement secure password handling practices:

const bcrypt = require('bcrypt');
const crypto = require('crypto');

class AuthService {
  // Hash passwords with bcrypt
  static async hashPassword(password: string): Promise {
    const saltRounds = 12;
    return await bcrypt.hash(password, saltRounds);
  }

  // Verify passwords
  static async verifyPassword(password: string, hash: string): Promise {
    return await bcrypt.compare(password, hash);
  }

  // Generate secure random tokens
  static generateToken(length: number = 32): string {
    return crypto.randomBytes(length).toString('hex');
  }

  // Password strength validation
  static validatePasswordStrength(password: string): boolean {
    const minLength = 8;
    const hasUpperCase = /[A-Z]/.test(password);
    const hasLowerCase = /[a-z]/.test(password);
    const hasNumbers = /\d/.test(password);
    const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(password);

    return password.length >= minLength &&
           hasUpperCase &&
           hasLowerCase &&
           hasNumbers &&
           hasSpecialChar;
  }
}

// Usage in registration
app.post('/register', async (req, res) => {
  const { email, password } = req.body;

  // Validate password strength
  if (!AuthService.validatePasswordStrength(password)) {
    return res.status(400).json({
      error: 'Password must be at least 8 characters long and contain uppercase, lowercase, numbers, and special characters'
    });
  }

  // Hash password
  const hashedPassword = await AuthService.hashPassword(password);

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

  res.status(201).json({ message: 'User created successfully' });
});

JWT Implementation

Secure JSON Web Token implementation:

const jwt = require('jsonwebtoken');
const crypto = require('crypto');

class JWTService {
  private static readonly ACCESS_TOKEN_EXPIRY = '15m';
  private static readonly REFRESH_TOKEN_EXPIRY = '7d';

  // Generate access token
  static generateAccessToken(payload: any): string {
    return jwt.sign(
      payload,
      process.env.JWT_ACCESS_SECRET!,
      { expiresIn: this.ACCESS_TOKEN_EXPIRY }
    );
  }

  // Generate refresh token
  static generateRefreshToken(): string {
    return jwt.sign(
      { type: 'refresh' },
      process.env.JWT_REFRESH_SECRET!,
      { expiresIn: this.REFRESH_TOKEN_EXPIRY }
    );
  }

  // Verify access token
  static verifyAccessToken(token: string): any {
    try {
      return jwt.verify(token, process.env.JWT_ACCESS_SECRET!);
    } catch (error) {
      throw new Error('Invalid access token');
    }
  }

  // Generate token pair
  static generateTokenPair(user: any) {
    const payload = {
      userId: user.id,
      email: user.email,
      role: user.role
    };

    return {
      accessToken: this.generateAccessToken(payload),
      refreshToken: this.generateRefreshToken()
    };
  }
}

// Authentication middleware
const authenticate = async (req, res, next) => {
  try {
    const authHeader = req.headers.authorization;
    
    if (!authHeader || !authHeader.startsWith('Bearer ')) {
      return res.status(401).json({ error: 'Access token required' });
    }

    const token = authHeader.substring(7);
    const decoded = JWTService.verifyAccessToken(token);
    
    // Attach user info to request
    req.user = decoded;
    next();
  } catch (error) {
    res.status(401).json({ error: 'Invalid or expired token' });
  }
};

Authorization Patterns

Role-Based Access Control (RBAC)

Implement role-based authorization:

// Role-based middleware
const authorize = (...roles) => {
  return (req, res, next) => {
    if (!req.user) {
      return res.status(401).json({ error: 'Authentication required' });
    }

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

    next();
  };
};

// Usage
app.get('/admin/users', authenticate, authorize('admin'), async (req, res) => {
  const users = await User.findAll();
  res.json(users);
});

app.get('/moderator/reports', authenticate, authorize('admin', 'moderator'), async (req, res) => {
  const reports = await Report.findAll();
  res.json(reports);
});

// Permission-based authorization
class PermissionService {
  static hasPermission(userRole: string, permission: string): boolean {
    const permissions = {
      admin: ['read', 'write', 'delete', 'manage_users'],
      moderator: ['read', 'write', 'moderate'],
      user: ['read', 'write_own'],
      guest: ['read']
    };

    return permissions[userRole]?.includes(permission) || false;
  }

  static requirePermission(permission: string) {
    return (req, res, next) => {
      if (!this.hasPermission(req.user.role, permission)) {
        return res.status(403).json({ error: 'Insufficient permissions' });
      }
      next();
    };
  }
}

// Usage
app.delete('/posts/:id', 
  authenticate, 
  PermissionService.requirePermission('delete'),
  async (req, res) => {
    // Delete post logic
  }
);

Input Validation and Sanitization

Request Validation

Validate all incoming data:

const { body, validationResult } = require('express-validator');
const sanitizeHtml = require('sanitize-html');

// Validation rules
const userValidation = [
  body('name')
    .trim()
    .isLength({ min: 2, max: 50 })
    .withMessage('Name must be between 2 and 50 characters')
    .matches(/^[a-zA-Z\s]+$/)
    .withMessage('Name can only contain letters and spaces'),
  
  body('email')
    .isEmail()
    .normalizeEmail()
    .withMessage('Valid email required'),
  
  body('password')
    .isLength({ min: 8 })
    .withMessage('Password must be at least 8 characters long')
    .matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/)
    .withMessage('Password must contain uppercase, lowercase, number, and special character'),
  
  body('bio')
    .optional()
    .trim()
    .isLength({ max: 500 })
    .withMessage('Bio cannot exceed 500 characters')
    .customSanitizer(value => sanitizeHtml(value))
];

// Validation middleware
const validateRequest = (req, res, next) => {
  const errors = validationResult(req);
  
  if (!errors.isEmpty()) {
    return res.status(400).json({
      error: 'Validation failed',
      details: errors.array()
    });
  }
  
  next();
};

// Custom validators
const customValidators = {
  // Validate MongoDB ObjectId
  isValidObjectId: (value) => {
    return mongoose.Types.ObjectId.isValid(value);
  },
  
  // Validate phone number
  isValidPhone: (value) => {
    const phoneRegex = /^\+?[\d\s-()]+$/;
    return phoneRegex.test(value) && value.replace(/\D/g, '').length >= 10;
  },
  
  // Validate URL
  isValidUrl: (value) => {
    try {
      new URL(value);
      return true;
    } catch {
      return false;
    }
  }
};

// Usage
app.post('/users', userValidation, validateRequest, async (req, res) => {
  const { name, email, password, bio } = req.body;
  
  // Process validated data
  const user = await User.create({
    name,
    email,
    password: await AuthService.hashPassword(password),
    bio
  });
  
  res.status(201).json({
    message: 'User created successfully',
    user: { id: user.id, name, email }
  });
});

File Upload Security

Secure file upload handling:

const multer = require('multer');
const path = require('path');

// Allowed file types
const allowedFileTypes = {
  'image/jpeg': '.jpg',
  'image/png': '.png',
  'image/gif': '.gif',
  'application/pdf': '.pdf',
  'text/plain': '.txt'
};

// File filter
const fileFilter = (req, file, cb) => {
  if (allowedFileTypes[file.mimetype]) {
    cb(null, true);
  } else {
    cb(new Error('Invalid file type'), false);
  }
};

// Multer configuration
const upload = multer({
  storage: multer.diskStorage({
    destination: (req, file, cb) => {
      cb(null, 'uploads/');
    },
    filename: (req, file, cb) => {
      // Generate unique filename
      const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
      const ext = allowedFileTypes[file.mimetype];
      cb(null, file.fieldname + '-' + uniqueSuffix + ext);
    }
  }),
  fileFilter,
  limits: {
    fileSize: 5 * 1024 * 1024, // 5MB limit
    files: 5 // Maximum 5 files
  }
});

// File upload endpoint
app.post('/upload', authenticate, upload.array('files', 5), async (req, res) => {
  try {
    if (!req.files || req.files.length === 0) {
      return res.status(400).json({ error: 'No files uploaded' });
    }

    const uploadedFiles = req.files.map(file => ({
      filename: file.filename,
      originalName: file.originalname,
      size: file.size,
      mimetype: file.mimetype,
      uploadedAt: new Date()
    }));

    // Save file metadata to database
    await FileMetadata.createMany(uploadedFiles);

    res.json({
      message: 'Files uploaded successfully',
      files: uploadedFiles
    });
  } catch (error) {
    res.status(500).json({ error: 'File upload failed' });
  }
});

Security Headers

Implement security headers for enhanced protection:

const helmet = require('helmet');

// Basic helmet configuration
app.use(helmet());

// Custom security headers
app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
      scriptSrc: ["'self'"],
      imgSrc: ["'self'", "data:", "https:"],
      fontSrc: ["'self'", "https://fonts.gstatic.com"],
      connectSrc: ["'self'"],
      frameSrc: ["'none'"],
      objectSrc: ["'none'"],
      mediaSrc: ["'self'"],
      manifestSrc: ["'self'"]
    }
  },
  hsts: {
    maxAge: 31536000,
    includeSubDomains: true,
    preload: true
  },
  noSniff: true,
  referrerPolicy: { policy: 'no-referrer' },
  xssFilter: true
}));

// Custom middleware for additional headers
app.use((req, res, next) => {
  // Prevent caching for sensitive routes
  if (req.path.startsWith('/api/') || req.path.includes('/admin')) {
    res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
    res.setHeader('Pragma', 'no-cache');
    res.setHeader('Expires', '0');
    res.setHeader('Surrogate-Control', 'no-store');
  }
  
  // API rate limiting headers
  res.setHeader('X-RateLimit-Limit', '100');
  res.setHeader('X-RateLimit-Remaining', '99');
  res.setHeader('X-RateLimit-Reset', new Date(Date.now() + 3600000).toISOString());
  
  next();
});

Rate Limiting

Implement rate limiting to prevent abuse:

const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');
const Redis = require('redis');

const redisClient = Redis.createClient({
  url: process.env.REDIS_URL
});

// General rate limiting
const generalLimiter = rateLimit({
  store: new RedisStore({
    client: redisClient,
    prefix: 'rl:general:'
  }),
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // Limit each IP to 100 requests per windowMs
  message: {
    error: 'Too many requests from this IP, please try again later.'
  },
  standardHeaders: true,
  legacyHeaders: false
});

// Strict rate limiting for sensitive endpoints
const strictLimiter = rateLimit({
  store: new RedisStore({
    client: redisClient,
    prefix: 'rl:strict:'
  }),
  windowMs: 15 * 60 * 1000,
  max: 5, // Limit to 5 requests per 15 minutes
  message: {
    error: 'Too many attempts, please try again later.'
  }
});

// Login rate limiting
const loginLimiter = rateLimit({
  store: new RedisStore({
    client: redisClient,
    prefix: 'rl:login:'
  }),
  windowMs: 15 * 60 * 1000,
  max: 10,
  skipSuccessfulRequests: true,
  keyGenerator: (req) => {
    return `login:${req.body.email || req.ip}`;
  }
});

// Apply rate limiting
app.use('/api/', generalLimiter);
app.post('/api/login', loginLimiter);
app.post('/api/register', strictLimiter);
app.post('/api/forgot-password', strictLimiter);

Secure Error Handling

Implement secure error handling that doesn't leak sensitive information:

// Custom error classes
class AppError extends Error {
  constructor(message: string, public statusCode: number, public isOperational: boolean = true) {
    super(message);
    this.name = this.constructor.name;
    Error.captureStackTrace(this, this.constructor);
  }
}

class ValidationError extends AppError {
  constructor(message: string, public details: any) {
    super(message, 400);
  }
}

class AuthenticationError extends AppError {
  constructor(message: string = 'Authentication failed') {
    super(message, 401);
  }
}

class AuthorizationError extends AppError {
  constructor(message: string = 'Insufficient permissions') {
    super(message, 403);
  }
}

// Global error handler
const errorHandler = (err, req, res, next) => {
  let error = { ...err };
  error.message = err.message;

  // Log error for debugging
  console.error('Error:', {
    message: err.message,
    stack: err.stack,
    url: req.url,
    method: req.method,
    ip: req.ip,
    userAgent: req.get('User-Agent')
  });

  // Mongoose validation error
  if (err.name === 'ValidationError') {
    const message = Object.values(err.errors).map(val => val.message);
    error = new ValidationError('Validation failed', message);
  }

  // Mongoose duplicate key error
  if (err.code === 11000) {
    const field = Object.keys(err.keyValue)[0];
    error = new ValidationError(`${field} already exists`);
  }

  // JWT errors
  if (err.name === 'JsonWebTokenError') {
    error = new AuthenticationError('Invalid token');
  }

  if (err.name === 'TokenExpiredError') {
    error = new AuthenticationError('Token expired');
  }

  // Production vs Development responses
  const response = {
    success: false,
    error: error.message || 'Internal server error'
  };

  // Include stack trace in development
  if (process.env.NODE_ENV === 'development') {
    response.stack = error.stack;
  }

  // Include validation details
  if (error instanceof ValidationError) {
    response.details = error.details;
  }

  res.status(error.statusCode || 500).json(response);
};

// 404 handler
const notFoundHandler = (req, res, next) => {
  const error = new AppError(`Route ${req.originalUrl} not found`, 404);
  next(error);
};

// Apply error handlers
app.use(notFoundHandler);
app.use(errorHandler);

Security Monitoring

Implement security monitoring and logging:

const winston = require('winston');

// Security logger
const securityLogger = winston.createLogger({
  level: 'info',
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.json()
  ),
  transports: [
    new winston.transports.File({ filename: 'logs/security.log' }),
    new winston.transports.File({ filename: 'logs/security-error.log', level: 'error' })
  ]
});

// Security monitoring middleware
const securityMonitor = (req, res, next) => {
  const startTime = Date.now();
  
  // Log security events
  res.on('finish', () => {
    const duration = Date.now() - startTime;
    const logData = {
      timestamp: new Date().toISOString(),
      ip: req.ip,
      userAgent: req.get('User-Agent'),
      method: req.method,
      url: req.originalUrl,
      statusCode: res.statusCode,
      duration,
      userId: req.user?.id
    };

    // Log suspicious activities
    if (res.statusCode >= 400) {
      securityLogger.warn('Security event', logData);
    }

    // Log authentication attempts
    if (req.path.includes('/login') || req.path.includes('/auth')) {
      securityLogger.info('Authentication attempt', logData);
    }

    // Log admin activities
    if (req.path.includes('/admin') && req.user) {
      securityLogger.info('Admin activity', logData);
    }
  });

  next();
};

// Intrusion detection
class IntrusionDetector {
  static detectSuspiciousActivity(req) {
    const suspiciousPatterns = [
      /\.\./,           // Directory traversal
      /