Node.js & Express

Security Best Practices

20 min Lesson 35 of 40

Node.js Security Best Practices

Security is critical for any production application. Node.js applications face various security threats, from injection attacks to dependency vulnerabilities. This lesson covers essential security practices to protect your applications.

OWASP Top 10 for Node.js

The Open Web Application Security Project (OWASP) maintains a list of the most critical web application security risks:

OWASP Top 10 (2021):
  1. Broken Access Control: Improper authorization checks
  2. Cryptographic Failures: Weak encryption, exposed secrets
  3. Injection: SQL, NoSQL, command injection
  4. Insecure Design: Missing security controls
  5. Security Misconfiguration: Default credentials, verbose errors
  6. Vulnerable Components: Outdated dependencies
  7. Authentication Failures: Weak passwords, session management
  8. Data Integrity Failures: Insecure deserialization
  9. Logging Failures: Insufficient monitoring
  10. Server-Side Request Forgery (SSRF): Unvalidated redirects

SQL Injection Prevention

SQL injection occurs when untrusted data is included in SQL queries without proper sanitization:

// BAD: Vulnerable to SQL injection
app.get('/users/:id', async (req, res) => {
  const userId = req.params.id;
  const query = `SELECT * FROM users WHERE id = ${userId}`;
  // Attacker could send: /users/1 OR 1=1
  const user = await db.query(query);
  res.json(user);
});

// GOOD: Using parameterized queries (prepared statements)
app.get('/users/:id', async (req, res) => {
  const userId = req.params.id;
  const query = 'SELECT * FROM users WHERE id = ?';
  const user = await db.query(query, [userId]);
  res.json(user);
});

// BETTER: Using ORM (e.g., Sequelize, TypeORM)
app.get('/users/:id', async (req, res) => {
  const user = await User.findByPk(req.params.id);
  res.json(user);
});

// Input validation with express-validator
const { param, validationResult } = require('express-validator');

app.get('/users/:id',
  param('id').isInt().toInt(),
  async (req, res) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      return res.status(400).json({ errors: errors.array() });
    }
    const user = await User.findByPk(req.params.id);
    res.json(user);
  }
);

NoSQL Injection Prevention

NoSQL databases like MongoDB are also vulnerable to injection attacks:

// BAD: Vulnerable to NoSQL injection
app.post('/login', async (req, res) => {
  const { username, password } = req.body;
  // Attacker could send: {username: {$gt: ""}, password: {$gt: ""}}
  const user = await User.findOne({ username, password });
  if (user) {
    res.json({ success: true });
  }
});

// GOOD: Sanitize inputs
const mongoSanitize = require('express-mongo-sanitize');
app.use(mongoSanitize());

app.post('/login', async (req, res) => {
  const { username, password } = req.body;

  // Validate input types
  if (typeof username !== 'string' || typeof password !== 'string') {
    return res.status(400).json({ error: 'Invalid input' });
  }

  const user = await User.findOne({ username });
  if (!user) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  // Use bcrypt to compare passwords
  const isValid = await bcrypt.compare(password, user.passwordHash);
  if (isValid) {
    res.json({ success: true });
  } else {
    res.status(401).json({ error: 'Invalid credentials' });
  }
});

Cross-Site Scripting (XSS) Prevention

XSS attacks inject malicious scripts into web pages viewed by other users:

// Install helmet for security headers
const helmet = require('helmet');
app.use(helmet());

// Install xss-clean to sanitize user input
const xss = require('xss-clean');
app.use(xss());

// BAD: Rendering unsanitized user input
app.get('/profile/:username', async (req, res) => {
  const user = await User.findOne({ username: req.params.username });
  res.send(`<h1>${user.bio}</h1>`); // XSS vulnerability!
});

// GOOD: Escape HTML in templates
const escapeHtml = require('escape-html');

app.get('/profile/:username', async (req, res) => {
  const user = await User.findOne({ username: req.params.username });
  res.send(`<h1>${escapeHtml(user.bio)}</h1>`);
});

// BETTER: Use templating engine that auto-escapes
app.set('view engine', 'ejs'); // or pug, handlebars, etc.

app.get('/profile/:username', async (req, res) => {
  const user = await User.findOne({ username: req.params.username });
  res.render('profile', { user }); // EJS auto-escapes by default
});

// Content Security Policy (CSP)
app.use(helmet.contentSecurityPolicy({
  directives: {
    defaultSrc: ["'self'"],
    scriptSrc: ["'self'", "'trusted-cdn.com'"],
    styleSrc: ["'self'", "'unsafe-inline'"],
    imgSrc: ["'self'", "data:", "https:"],
  }
}));

Cross-Site Request Forgery (CSRF) Protection

CSRF attacks trick authenticated users into performing unwanted actions:

const csrf = require('csurf');
const cookieParser = require('cookie-parser');

app.use(cookieParser());

// CSRF protection middleware
const csrfProtection = csrf({ cookie: true });

// Add CSRF token to forms
app.get('/form', csrfProtection, (req, res) => {
  res.render('form', { csrfToken: req.csrfToken() });
});

// Validate CSRF token on POST
app.post('/submit', csrfProtection, (req, res) => {
  // CSRF token is automatically validated
  res.json({ success: true });
});

// For API routes, use double-submit cookie pattern
app.use((req, res, next) => {
  if (req.method === 'POST' || req.method === 'PUT' || req.method === 'DELETE') {
    const token = req.headers['x-csrf-token'];
    const cookieToken = req.cookies.csrfToken;

    if (!token || token !== cookieToken) {
      return res.status(403).json({ error: 'Invalid CSRF token' });
    }
  }
  next();
});

Dependency Auditing

Vulnerable dependencies are a major security risk. Regularly audit and update your packages:

// Run npm audit to check for vulnerabilities
npm audit

// Fix vulnerabilities automatically (if possible)
npm audit fix

// Force fix (may introduce breaking changes)
npm audit fix --force

// Generate detailed audit report
npm audit --json > audit-report.json

// Use Snyk for continuous monitoring
npm install -g snyk
snyk auth
snyk test         // Test for vulnerabilities
snyk monitor      // Monitor project continuously
snyk wizard       // Interactive vulnerability fixing

// Add to package.json scripts
{
  "scripts": {
    "audit": "npm audit",
    "audit:fix": "npm audit fix",
    "security:check": "snyk test"
  }
}

// Use npm-check-updates to keep dependencies current
npm install -g npm-check-updates
ncu               // Check for updates
ncu -u            // Update package.json
npm install       // Install updated packages

Security Headers

HTTP security headers protect against common attacks:

const helmet = require('helmet');

// Use helmet with custom configuration
app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'", "'trusted-cdn.com'"],
      styleSrc: ["'self'", "'unsafe-inline'"],
      imgSrc: ["'self'", "data:", "https:"],
      connectSrc: ["'self'"],
      fontSrc: ["'self'"],
      objectSrc: ["'none'"],
      mediaSrc: ["'self'"],
      frameSrc: ["'none'"],
    }
  },
  hsts: {
    maxAge: 31536000,           // 1 year
    includeSubDomains: true,
    preload: true
  },
  noSniff: true,                // X-Content-Type-Options: nosniff
  frameguard: {                 // X-Frame-Options: DENY
    action: 'deny'
  },
  xssFilter: true,              // X-XSS-Protection: 1; mode=block
  referrerPolicy: {
    policy: 'strict-origin-when-cross-origin'
  }
}));

// Additional security headers
app.use((req, res, next) => {
  res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
  res.setHeader('X-Powered-By', ''); // Remove X-Powered-By header
  next();
});

Environment Variable Security

Protect sensitive configuration data:

// Use dotenv for local development only
if (process.env.NODE_ENV !== 'production') {
  require('dotenv').config();
}

// Validate required environment variables
const requiredEnvVars = [
  'DATABASE_URL',
  'JWT_SECRET',
  'API_KEY'
];

requiredEnvVars.forEach((varName) => {
  if (!process.env[varName]) {
    throw new Error(`Environment variable ${varName} is required`);
  }
});

// Use strong secrets (minimum 32 characters)
const crypto = require('crypto');

// Generate a secure random secret
const generateSecret = () => {
  return crypto.randomBytes(32).toString('hex');
};

console.log('Generated secret:', generateSecret());

// Store secrets in secure vaults (production)
// - AWS Secrets Manager
// - Azure Key Vault
// - HashiCorp Vault
// - Google Secret Manager

// Example with AWS Secrets Manager
const AWS = require('aws-sdk');
const secretsManager = new AWS.SecretsManager({ region: 'us-east-1' });

async function getSecret(secretName) {
  const data = await secretsManager.getSecretValue({ SecretId: secretName }).promise();
  return JSON.parse(data.SecretString);
}

// Usage
const dbCredentials = await getSecret('prod/database/credentials');

HTTPS Setup

Always use HTTPS in production to encrypt data in transit:

const https = require('https');
const fs = require('fs');

// Load SSL certificates
const privateKey = fs.readFileSync('/path/to/private-key.pem', 'utf8');
const certificate = fs.readFileSync('/path/to/certificate.pem', 'utf8');
const ca = fs.readFileSync('/path/to/ca-bundle.pem', 'utf8');

const credentials = {
  key: privateKey,
  cert: certificate,
  ca: ca
};

// Create HTTPS server
const httpsServer = https.createServer(credentials, app);

httpsServer.listen(443, () => {
  console.log('HTTPS Server running on port 443');
});

// Redirect HTTP to HTTPS
const http = require('http');

http.createServer((req, res) => {
  res.writeHead(301, { Location: `https://${req.headers.host}${req.url}` });
  res.end();
}).listen(80);

// Force HTTPS in Express
app.use((req, res, next) => {
  if (req.secure || req.headers['x-forwarded-proto'] === 'https') {
    next();
  } else {
    res.redirect(301, `https://${req.headers.host}${req.url}`);
  }
});

Rate Limiting

Protect against brute-force and DDoS attacks:

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

// General rate limiter
const generalLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100,                  // Limit each IP to 100 requests per windowMs
  message: 'Too many requests, please try again later',
  standardHeaders: true,
  legacyHeaders: false,
});

app.use(generalLimiter);

// Strict limiter for auth endpoints
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,
  skipSuccessfulRequests: true, // Don't count successful logins
  message: 'Too many login attempts, please try again later'
});

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

// Use Redis for distributed rate limiting
const redisClient = require('redis').createClient({
  host: 'localhost',
  port: 6379
});

const distributedLimiter = rateLimit({
  store: new RedisStore({
    client: redisClient,
    prefix: 'rl:',
  }),
  windowMs: 15 * 60 * 1000,
  max: 100
});

app.use(distributedLimiter);

Secure Password Hashing

const bcrypt = require('bcrypt');

// Hash password before storing
app.post('/register', async (req, res) => {
  const { username, password } = req.body;

  // Validate password strength
  const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/;
  if (!passwordRegex.test(password)) {
    return res.status(400).json({
      error: 'Password must be at least 8 characters with uppercase, lowercase, number, and special character'
    });
  }

  // Hash password with bcrypt (cost factor: 10-12 recommended)
  const saltRounds = 12;
  const passwordHash = await bcrypt.hash(password, saltRounds);

  // Store user with hashed password
  const user = await User.create({
    username,
    passwordHash
  });

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

// Verify password during login
app.post('/login', async (req, res) => {
  const { username, password } = req.body;

  const user = await User.findOne({ username });
  if (!user) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

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

  // Generate JWT token
  const token = jwt.sign(
    { userId: user.id },
    process.env.JWT_SECRET,
    { expiresIn: '1h' }
  );

  res.json({ token });
});
Password Security Rules:
  • Never store plaintext passwords
  • Use bcrypt, argon2, or scrypt (not MD5 or SHA1)
  • Use salt rounds between 10-12 for bcrypt
  • Enforce strong password policies (minimum 8 characters, complexity requirements)
  • Implement account lockout after failed login attempts
  • Use two-factor authentication (2FA) for sensitive accounts

Practice Exercise

Task: Secure a vulnerable Node.js application:

  1. Run npm audit and fix all vulnerabilities
  2. Implement helmet for security headers
  3. Add input validation with express-validator
  4. Implement CSRF protection
  5. Add rate limiting for auth endpoints
  6. Set up HTTPS with Let's Encrypt
  7. Implement secure password hashing with bcrypt
  8. Add MongoDB sanitization
  9. Configure Content Security Policy
  10. Test security with OWASP ZAP or Burp Suite
Security Checklist for Production:
  • ✓ Use HTTPS everywhere
  • ✓ Keep dependencies updated (npm audit)
  • ✓ Implement rate limiting
  • ✓ Use security headers (helmet)
  • ✓ Validate and sanitize all inputs
  • ✓ Use parameterized queries
  • ✓ Hash passwords with bcrypt
  • ✓ Implement CSRF protection
  • ✓ Set up logging and monitoring
  • ✓ Use environment variables for secrets
  • ✓ Implement authentication and authorization
  • ✓ Regular security testing
Useful Security Packages:
  • helmet: Security headers
  • express-rate-limit: Rate limiting
  • express-validator: Input validation
  • bcrypt: Password hashing
  • csurf: CSRF protection
  • express-mongo-sanitize: NoSQL injection prevention
  • xss-clean: XSS prevention
  • hpp: HTTP parameter pollution prevention
  • cors: CORS configuration
  • snyk: Vulnerability scanning