Skip to main content

Security Best Practices

Security isn’t optional—it’s a fundamental requirement for any production application. This chapter covers essential security practices every Node.js developer must implement.

OWASP Top 10 for Node.js

VulnerabilityDescriptionPrevention
InjectionSQL, NoSQL, Command injectionInput validation, parameterized queries
Broken AuthWeak passwords, session hijackingStrong auth, JWT best practices
Sensitive Data ExposureUnencrypted dataHTTPS, encryption at rest
XXEXML External EntitiesDisable XML parsing or use safe parsers
Broken Access ControlPrivilege escalationRBAC, resource ownership validation
Security MisconfigurationDefault credentials, verbose errorsSecure defaults, minimal information
XSSCross-Site ScriptingOutput encoding, CSP headers
Insecure DeserializationObject manipulationInput validation, schema enforcement
Vulnerable ComponentsOutdated dependenciesRegular updates, npm audit
Insufficient LoggingNo audit trailComprehensive logging and monitoring

Input Validation and Sanitization

Never trust user input. Validate and sanitize everything.
const Joi = require('joi');
const validator = require('validator');
const xss = require('xss');

// Joi schema validation
const userSchema = Joi.object({
  email: Joi.string().email().required(),
  password: Joi.string().min(8).max(128).required(),
  name: Joi.string().min(2).max(100).pattern(/^[a-zA-Z\s]+$/).required(),
  age: Joi.number().integer().min(18).max(120)
});

// Validation middleware
const validate = (schema) => (req, res, next) => {
  const { error, value } = schema.validate(req.body, {
    abortEarly: false,
    stripUnknown: true // Remove unknown fields
  });
  
  if (error) {
    return res.status(400).json({
      error: 'Validation failed',
      details: error.details.map(d => ({
        field: d.path.join('.'),
        message: d.message
      }))
    });
  }
  
  req.body = value;
  next();
};

// XSS prevention
const sanitizeInput = (input) => {
  if (typeof input === 'string') {
    return xss(input.trim());
  }
  if (typeof input === 'object') {
    const sanitized = {};
    for (const key in input) {
      sanitized[key] = sanitizeInput(input[key]);
    }
    return sanitized;
  }
  return input;
};

// Additional validation
const isValidEmail = (email) => validator.isEmail(email);
const isValidURL = (url) => validator.isURL(url, { require_protocol: true });
const isAlphanumeric = (str) => validator.isAlphanumeric(str);

SQL Injection Prevention

// ❌ NEVER do this - vulnerable to SQL injection
const query = `SELECT * FROM users WHERE id = ${userId}`;

// ✅ Use parameterized queries with pg
const { Pool } = require('pg');
const pool = new Pool();

const getUser = async (userId) => {
  const result = await pool.query(
    'SELECT * FROM users WHERE id = $1',
    [userId]
  );
  return result.rows[0];
};

// ✅ With Prisma (automatically safe)
const user = await prisma.user.findUnique({
  where: { id: userId }
});

// ✅ With Mongoose (automatically safe with proper usage)
const user = await User.findById(userId);

// ⚠️ Be careful with $where and string-based queries
// ❌ Vulnerable
User.find({ $where: `this.role === '${role}'` });

// ✅ Safe
User.find({ role: role });

NoSQL Injection Prevention

// ❌ Vulnerable to NoSQL injection
app.post('/login', async (req, res) => {
  const user = await User.findOne({
    email: req.body.email,
    password: req.body.password // Could be { $gt: '' }
  });
});

// ✅ Validate types before querying
const mongoSanitize = require('express-mongo-sanitize');

app.use(mongoSanitize()); // Remove $ and . from req.body/query/params

// Or manually validate
app.post('/login', async (req, res) => {
  const { email, password } = req.body;
  
  if (typeof email !== 'string' || typeof password !== 'string') {
    return res.status(400).json({ error: 'Invalid input' });
  }
  
  const user = await User.findOne({ email });
  if (user && await bcrypt.compare(password, user.password)) {
    // Success
  }
});

Authentication Security

const bcrypt = require('bcrypt');
const rateLimit = require('express-rate-limit');

// Password hashing
const SALT_ROUNDS = 12; // Increase for more security (slower)

const hashPassword = async (password) => {
  return bcrypt.hash(password, SALT_ROUNDS);
};

const verifyPassword = async (password, hash) => {
  return bcrypt.compare(password, hash);
};

// Rate limiting for auth endpoints
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 5, // 5 attempts
  message: { error: 'Too many login attempts, try again later' },
  standardHeaders: true,
  legacyHeaders: false
});

app.post('/api/auth/login', authLimiter, async (req, res) => {
  // Login logic
});

// Password strength validation
const passwordSchema = Joi.string()
  .min(8)
  .max(128)
  .pattern(/[a-z]/, 'lowercase')
  .pattern(/[A-Z]/, 'uppercase')
  .pattern(/[0-9]/, 'number')
  .pattern(/[!@#$%^&*]/, 'special')
  .required();

// Account lockout
const MAX_ATTEMPTS = 5;
const LOCKOUT_TIME = 15 * 60 * 1000; // 15 minutes

const loginAttempts = new Map();

const checkLockout = (email) => {
  const attempts = loginAttempts.get(email);
  if (!attempts) return false;
  
  if (attempts.count >= MAX_ATTEMPTS) {
    if (Date.now() - attempts.lastAttempt < LOCKOUT_TIME) {
      return true; // Account is locked
    }
    loginAttempts.delete(email); // Reset after lockout period
  }
  return false;
};

Security Headers with Helmet

const helmet = require('helmet');

app.use(helmet()); // Sets various HTTP headers

// Or configure individually
app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
      fontSrc: ["'self'", "https://fonts.gstatic.com"],
      imgSrc: ["'self'", "data:", "https:"],
      scriptSrc: ["'self'"],
      connectSrc: ["'self'", "https://api.example.com"],
    }
  },
  crossOriginEmbedderPolicy: true,
  crossOriginOpenerPolicy: true,
  crossOriginResourcePolicy: { policy: "same-site" },
  dnsPrefetchControl: { allow: false },
  frameguard: { action: "deny" },
  hidePoweredBy: true,
  hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
  ieNoOpen: true,
  noSniff: true,
  originAgentCluster: true,
  permittedCrossDomainPolicies: { permittedPolicies: "none" },
  referrerPolicy: { policy: "strict-origin-when-cross-origin" },
  xssFilter: true
}));

CORS Configuration

const cors = require('cors');

// ❌ Too permissive
app.use(cors()); // Allows all origins

// ✅ Restrictive configuration
const corsOptions = {
  origin: (origin, callback) => {
    const allowedOrigins = [
      'https://example.com',
      'https://app.example.com'
    ];
    
    // Allow requests with no origin (mobile apps, curl)
    if (!origin) return callback(null, true);
    
    if (allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  },
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  credentials: true,
  maxAge: 86400 // Cache preflight for 24 hours
};

app.use(cors(corsOptions));

Environment Variables Security

// ✅ Use dotenv for local development
require('dotenv').config();

// ❌ Never commit .env files
// Add to .gitignore:
// .env
// .env.local
// .env.production

// ✅ Validate required env vars on startup
const requiredEnvVars = [
  'NODE_ENV',
  'DATABASE_URL',
  'JWT_SECRET',
  'SESSION_SECRET'
];

const missingVars = requiredEnvVars.filter(v => !process.env[v]);
if (missingVars.length > 0) {
  console.error('Missing required environment variables:', missingVars);
  process.exit(1);
}

// ✅ Use strong secrets
// Generate with: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"

// ✅ Different secrets per environment
// Never use development secrets in production

Dependency Security

# Check for vulnerabilities
npm audit

# Auto-fix vulnerabilities
npm audit fix

# Force major updates (may break things)
npm audit fix --force

# Use in CI/CD
npm audit --audit-level=high

# Alternative: use Snyk
npx snyk test
// package.json - lock engines
{
  "engines": {
    "node": ">=18.0.0",
    "npm": ">=9.0.0"
  }
}

File Upload Security

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

// Secure file upload configuration
const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    cb(null, 'uploads/');
  },
  filename: (req, file, cb) => {
    // Generate random filename
    const ext = path.extname(file.originalname);
    const randomName = crypto.randomBytes(16).toString('hex');
    cb(null, `${randomName}${ext}`);
  }
});

const fileFilter = (req, file, cb) => {
  // Allowed file types
  const allowedTypes = ['image/jpeg', 'image/png', 'image/gif'];
  const allowedExtensions = ['.jpg', '.jpeg', '.png', '.gif'];
  
  const ext = path.extname(file.originalname).toLowerCase();
  
  if (allowedTypes.includes(file.mimetype) && allowedExtensions.includes(ext)) {
    cb(null, true);
  } else {
    cb(new Error('Invalid file type'), false);
  }
};

const upload = multer({
  storage,
  fileFilter,
  limits: {
    fileSize: 5 * 1024 * 1024, // 5MB
    files: 1 // Max number of files
  }
});

// Scan uploaded files for malware (in production)
// Use ClamAV or cloud-based scanning services
const session = require('express-session');

app.use(session({
  secret: process.env.SESSION_SECRET,
  name: 'sessionId', // Change from default 'connect.sid'
  resave: false,
  saveUninitialized: false,
  cookie: {
    secure: process.env.NODE_ENV === 'production', // HTTPS only
    httpOnly: true, // Not accessible via JavaScript
    sameSite: 'strict', // CSRF protection
    maxAge: 24 * 60 * 60 * 1000, // 24 hours
    domain: '.example.com' // For cross-subdomain (if needed)
  }
}));

Logging Security Events

const logger = require('./config/logger');

// Log security events
const securityLogger = {
  loginSuccess: (userId, ip) => {
    logger.info('Login success', { event: 'auth.login.success', userId, ip });
  },
  
  loginFailure: (email, ip, reason) => {
    logger.warn('Login failure', { event: 'auth.login.failure', email, ip, reason });
  },
  
  suspiciousActivity: (details) => {
    logger.error('Suspicious activity', { event: 'security.suspicious', ...details });
  },
  
  accessDenied: (userId, resource) => {
    logger.warn('Access denied', { event: 'auth.access.denied', userId, resource });
  }
};

// Middleware to log requests
app.use((req, res, next) => {
  res.on('finish', () => {
    if (res.statusCode >= 400) {
      logger.warn('Request failed', {
        method: req.method,
        url: req.originalUrl,
        status: res.statusCode,
        ip: req.ip,
        userId: req.user?.id
      });
    }
  });
  next();
});

Security Checklist

Before deploying to production, verify:
  • All dependencies are up-to-date (npm audit)
  • Environment variables are properly configured
  • HTTPS is enforced
  • Security headers are set (Helmet)
  • CORS is properly configured
  • Rate limiting is enabled
  • Input validation is implemented
  • Authentication is secure (bcrypt, JWT best practices)
  • Sensitive data is encrypted
  • Error messages don’t leak information
  • Logging captures security events
  • Database queries are parameterized
  • File uploads are validated and scanned

Summary

  • Validate all input - Never trust client data
  • Prevent injection - Use parameterized queries
  • Secure authentication - Hash passwords, rate limit, lockout
  • Set security headers - Use Helmet
  • Configure CORS properly - Whitelist origins
  • Keep dependencies updated - Run npm audit regularly
  • Handle files securely - Validate type, size, scan for malware
  • Log security events - Audit trail for incidents
  • Use HTTPS everywhere - Encrypt data in transit
  • Follow the principle of least privilege - Minimal permissions