TechLead
Lesson 13 of 16
5 min read
Node.js

Node.js Security Best Practices

Secure your Node.js apps: input validation, injection prevention, rate limiting, secure headers, and dependency scanning

Security is Not Optional

Every Node.js application that handles user data or is accessible over the network must be secured. Security isn't something you add later; it must be baked into your development process from day one.

🛡️ OWASP Top Risks for Node.js

Injection

SQL, NoSQL, command injection via unsanitized input

Broken Auth

Weak passwords, missing rate limits, insecure tokens

XSS

Cross-site scripting through unescaped user content

Dependency Vulns

Exploits in third-party packages

Input Validation with Zod

Never trust user input. Validate and sanitize everything:

const { z } = require('zod');

// Define strict schemas
const UserSchema = z.object({
  name: z.string().min(2).max(100).trim(),
  email: z.string().email().toLowerCase(),
  age: z.number().int().min(13).max(150),
  role: z.enum(['user', 'admin', 'moderator']),
  bio: z.string().max(500).optional(),
});

// Validate in route handler
app.post('/api/users', (req, res) => {
  const result = UserSchema.safeParse(req.body);

  if (!result.success) {
    return res.status(400).json({
      error: 'Validation failed',
      details: result.error.issues.map(i => ({
        field: i.path.join('.'),
        message: i.message
      }))
    });
  }

  // result.data is typed and sanitized
  const user = createUser(result.data);
  res.json(user);
});

// Validate query params
const SearchSchema = z.object({
  q: z.string().min(1).max(200),
  page: z.coerce.number().int().min(1).default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
});

app.get('/api/search', (req, res) => {
  const params = SearchSchema.parse(req.query);
  // params.q, params.page, params.limit are safe
});

Preventing Injection Attacks

// SQL Injection Prevention
// BAD: String concatenation
const bad = `SELECT * FROM users WHERE id = '${userId}'`;

// GOOD: Parameterized queries
const { rows } = await pool.query(
  'SELECT * FROM users WHERE id = $1 AND role = $2',
  [userId, role]
);

// NoSQL Injection Prevention (MongoDB)
// BAD: Direct user input in query
const bad2 = await db.users.find({ username: req.body.username });
// Attacker sends: { "username": { "$gt": "" } } -> returns all users

// GOOD: Validate types before querying
const username = String(req.body.username); // Force to string
const user = await db.users.findOne({ username });

// Command Injection Prevention
const { execFile } = require('child_process');

// BAD: Shell injection via exec
const bad3 = exec(`convert ${userInput}.png output.jpg`);
// Attacker sends: "; rm -rf /" as userInput

// GOOD: execFile doesn't use shell
execFile('convert', [userInput + '.png', 'output.jpg'], (err, stdout) => {
  // Safe: arguments are passed as array, not shell string
});

// Path Traversal Prevention
const path = require('path');

function safeFilePath(baseDir, userInput) {
  const resolved = path.resolve(baseDir, userInput);
  if (!resolved.startsWith(path.resolve(baseDir))) {
    throw new Error('Path traversal detected');
  }
  return resolved;
}

// Attacker sends: "../../etc/passwd"
safeFilePath('/uploads', '../../etc/passwd'); // Throws error

Rate Limiting

// Simple in-memory rate limiter
class RateLimiter {
  constructor(windowMs, maxRequests) {
    this.windowMs = windowMs;
    this.maxRequests = maxRequests;
    this.clients = new Map();
  }

  isAllowed(clientId) {
    const now = Date.now();
    const client = this.clients.get(clientId) || { count: 0, resetAt: now + this.windowMs };

    // Reset window if expired
    if (now > client.resetAt) {
      client.count = 0;
      client.resetAt = now + this.windowMs;
    }

    client.count++;
    this.clients.set(clientId, client);

    return {
      allowed: client.count <= this.maxRequests,
      remaining: Math.max(0, this.maxRequests - client.count),
      resetAt: client.resetAt
    };
  }
}

// Usage as middleware
const limiter = new RateLimiter(15 * 60 * 1000, 100); // 100 requests per 15 min

function rateLimitMiddleware(req, res, next) {
  const clientIP = req.ip || req.socket.remoteAddress;
  const { allowed, remaining, resetAt } = limiter.isAllowed(clientIP);

  res.setHeader('X-RateLimit-Limit', '100');
  res.setHeader('X-RateLimit-Remaining', remaining);
  res.setHeader('X-RateLimit-Reset', Math.ceil(resetAt / 1000));

  if (!allowed) {
    return res.status(429).json({ error: 'Too many requests' });
  }

  next();
}

// For production, use Redis-backed rate limiting:
// npm install express-rate-limit rate-limit-redis

Secure Headers with Helmet

const helmet = require('helmet');
const express = require('express');
const app = express();

// Helmet sets 15+ security headers with one line
app.use(helmet());

// What helmet does:
// - Content-Security-Policy: Prevents XSS and data injection
// - X-Content-Type-Options: nosniff - Prevents MIME sniffing
// - X-Frame-Options: DENY - Prevents clickjacking
// - Strict-Transport-Security: Forces HTTPS
// - X-XSS-Protection: Legacy XSS filter
// - Referrer-Policy: Controls referrer info

// Custom CSP configuration
app.use(helmet.contentSecurityPolicy({
  directives: {
    defaultSrc: ["'self'"],
    scriptSrc: ["'self'", "https://cdn.example.com"],
    styleSrc: ["'self'", "'unsafe-inline'"],
    imgSrc: ["'self'", "data:", "https:"],
    connectSrc: ["'self'", "https://api.example.com"],
    fontSrc: ["'self'", "https://fonts.googleapis.com"],
    objectSrc: ["'none'"],
    upgradeInsecureRequests: [],
  }
}));

// CSRF Protection
const csrf = require('csurf');
app.use(csrf({ cookie: true }));

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

Environment Variables and Secrets

// NEVER hardcode secrets in source code
// BAD:
const apiKey = 'sk-1234567890abcdef';

// GOOD: Use environment variables
const apiKey = process.env.API_KEY;

// Validate required env vars at startup
function validateEnv() {
  const required = ['DATABASE_URL', 'JWT_SECRET', 'API_KEY'];
  const missing = required.filter(key => !process.env[key]);

  if (missing.length > 0) {
    console.error('Missing environment variables:', missing.join(', '));
    process.exit(1);
  }
}
validateEnv();

// .env file for local development (never commit!)
// npm install dotenv
require('dotenv').config();

// .env
// DATABASE_URL=postgresql://user:pass@localhost/db
// JWT_SECRET=your-secret-here
// API_KEY=sk-1234567890abcdef

// .gitignore:
// .env
// .env.local
// .env.production

// Dependency scanning
// npm audit                    # Check for known vulnerabilities
// npx snyk test               # Deep vulnerability scan
// npx socket-security npm audit  # Supply chain attack detection

💡 Key Takeaways

  • • Validate all input with schemas (Zod, Joi)
  • • Always use parameterized queries to prevent SQL injection
  • • Implement rate limiting on all public endpoints
  • • Use helmet for security headers in one line
  • • Never hardcode secrets; validate env vars at startup

Continue Learning