Published on

Node.js Security Hardening — The Production Checklist Most Teams Skip

Authors

Introduction

Security is often an afterthought. By then it's too late. This post covers the hardening checklist most production Node.js teams skip: HTTP headers, input validation, dependency auditing, and process isolation. Implement these and you'll catch 90% of attacks.

Helmet.js for HTTP Security Headers

Helmet sets critical HTTP headers preventing common attacks.

import express from 'express';
import helmet from 'helmet';

const app = express();

// Helmet: sets multiple headers automatically
app.use(helmet());

// Custom header configuration
app.use(
  helmet({
    // Content Security Policy: prevent XSS
    contentSecurityPolicy: {
      directives: {
        defaultSrc: ["'self'"],
        scriptSrc: ["'self'", "'unsafe-inline'"],
        styleSrc: ["'self'", "'unsafe-inline'"],
        imgSrc: ["'self'", 'data:', 'https:'],
        connectSrc: ["'self'", 'https://api.example.com'],
        fontSrc: ["'self'"],
        objectSrc: ["'none'"],
        mediaSrc: ["'self'"],
        frameSrc: ["'none'"],
      },
    },

    // HTTP Strict Transport Security
    hsts: {
      maxAge: 31536000, // 1 year
      includeSubDomains: true,
      preload: true,
    },

    // Prevent clickjacking
    frameguard: {
      action: 'deny',
    },

    // Disable X-Powered-By header
    hidePoweredBy: true,

    // Prevent MIME type sniffing
    noSniff: true,

    // XSS protection (legacy)
    xssFilter: true,

    // Referrer-Policy
    referrerPolicy: {
      policy: 'strict-origin-when-cross-origin',
    },

    // Permissions-Policy (formerly Feature-Policy)
    permissionsPolicy: {
      features: {
        camera: ["'none'"],
        microphone: ["'none'"],
        geolocation: ["'none'"],
      },
    },
  })
);

app.get('/data', (req, res) => {
  res.json({ data: 'secure' });
});

app.listen(3000);

// Headers set by helmet:
// Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
// Content-Security-Policy: default-src 'self'; ...
// X-Content-Type-Options: nosniff
// X-Frame-Options: DENY
// X-XSS-Protection: 1; mode=block
// Referrer-Policy: strict-origin-when-cross-origin

express-rate-limit with Redis Store

Rate limiting prevents brute force and DoS attacks.

import express from 'express';
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import Redis from 'ioredis';

const app = express();
const redis = new Redis('redis://localhost:6379');

// Generic rate limiter
const apiLimiter = rateLimit({
  store: new RedisStore({
    client: redis,
    prefix: 'rate-limit:',
  }),
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // 100 requests per window
  message: 'Too many requests, please try again later.',
  standardHeaders: true, // Return rate limit info in RateLimit-* headers
  legacyHeaders: false,
});

// Strict limiter for auth endpoints
const authLimiter = rateLimit({
  store: new RedisStore({
    client: redis,
    prefix: 'auth-limit:',
  }),
  windowMs: 15 * 60 * 1000,
  max: 5, // 5 login attempts per 15 minutes per IP
  skipSuccessfulRequests: true, // Don't count successful logins
  message: 'Too many login attempts, please try again later.',
});

// Login endpoint with strict rate limit
app.post('/login', authLimiter, async (req, res) => {
  const { email, password } = req.body;

  try {
    const user = await authenticateUser(email, password);
    res.json({ token: generateToken(user) });
  } catch (err) {
    res.status(401).json({ error: 'Invalid credentials' });
  }
});

// API endpoints with moderate rate limit
app.use('/api/', apiLimiter);

app.get('/api/data', (req, res) => {
  res.json({ data: 'protected' });
});

// Custom rate limiting per endpoint
const uploadLimiter = rateLimit({
  store: new RedisStore({
    client: redis,
    prefix: 'upload-limit:',
  }),
  windowMs: 60 * 60 * 1000, // 1 hour
  max: 10, // 10 uploads per hour
  keyGenerator: (req) => `${req.user?.id}`, // Per-user limit
});

app.post('/upload', uploadLimiter, (req, res) => {
  res.json({ uploaded: true });
});

function authenticateUser(email: string, password: string): any {
  // Implementation
  return { id: 1, email };
}

function generateToken(user: any): string {
  return 'token';
}

app.listen(3000);

SQL Injection: Prevention in ORMs

Raw SQL is vulnerable. Always use parameterized queries.

import express from 'express';
import pg from 'pg';

const pool = new pg.Pool();
const app = express();

// VULNERABLE: string concatenation
async function insecureQuery(userId: string): Promise<any> {
  // NEVER do this!
  const query = `SELECT * FROM users WHERE id = ${userId}`;

  try {
    const result = await pool.query(query);
    return result.rows[0];
  } catch (err) {
    console.error('Query error:', err);
  }
}

// Attack: userId = "1 OR 1=1" → SELECT * FROM users WHERE id = 1 OR 1=1
// Returns all users instead of one

// SECURE: parameterized query
async function secureQuery(userId: string): Promise<any> {
  const query = 'SELECT * FROM users WHERE id = $1';
  const params = [userId];

  try {
    const result = await pool.query(query, params);
    return result.rows[0];
  } catch (err) {
    console.error('Query error:', err);
  }
}

app.get('/user/:id', async (req, res) => {
  const user = await secureQuery(req.params.id);

  if (!user) {
    res.status(404).json({ error: 'User not found' });
  } else {
    res.json(user);
  }
});

// ORM examples (already parameterized)
import { DataSource } from 'typeorm';

const datasource = new DataSource({
  type: 'postgres',
  host: 'localhost',
  database: 'mydb',
  entities: [],
  synchronize: true,
});

// TypeORM: safe by default
async function queryWithORM(userId: string): Promise<any> {
  const user = await datasource.getRepository('User').findOne({
    where: { id: userId },
  });
  return user;
}

// Sequelize: safe with parameterized queries
import { Sequelize, DataTypes } from 'sequelize';

const sequelize = new Sequelize('postgres://localhost/mydb');

const User = sequelize.define(
  'User',
  {
    id: {
      type: DataTypes.INTEGER,
      primaryKey: true,
    },
    email: DataTypes.STRING,
  },
  { timestamps: false }
);

// Safe: parameterized
const user = await User.findByPk(userId);

app.listen(3000);

Prototype Pollution Prevention

Protect against object property injection attacks.

import express from 'express';

const app = express();
app.use(express.json());

// VULNERABLE: Object.assign or spread operator with untrusted data
function vulnerableUpdate(obj: any, data: any): any {
  // If data = { __proto__: { isAdmin: true } }
  // This pollutes Object.prototype
  return Object.assign(obj, data);
}

// Attack scenario
const attacker = {
  __proto__: {
    isAdmin: true,
  },
};

const user = { name: 'Alice', isAdmin: false };
Object.assign(user, attacker);
// Now {} .isAdmin === true (prototype polluted!)

// FIX 1: Explicit property allowlist
function safeUpdate(obj: any, data: any): any {
  const allowedKeys = ['name', 'email', 'age'];
  const sanitized: any = {};

  for (const key of allowedKeys) {
    if (key in data) {
      sanitized[key] = data[key];
    }
  }

  return Object.assign(obj, sanitized);
}

// FIX 2: Use Object.create(null) to prevent prototype pollution
function updateWithNull(obj: any, data: any): any {
  const clean = Object.create(null);
  Object.assign(clean, data);

  // Now check for dangerous keys
  for (const key of Object.keys(clean)) {
    if (
      key === '__proto__' ||
      key === 'constructor' ||
      key === 'prototype'
    ) {
      throw new Error(`Dangerous key: ${key}`);
    }
  }

  return Object.assign(obj, clean);
}

// FIX 3: JSON schema validation (recommended)
import Ajv from 'ajv';

const ajv = new Ajv();
const userSchema = {
  type: 'object',
  properties: {
    name: { type: 'string' },
    email: { type: 'string', format: 'email' },
    age: { type: 'integer' },
  },
  required: ['name', 'email'],
  additionalProperties: false, // Reject unknown keys
};

const validateUser = ajv.compile(userSchema);

app.post('/user', (req, res) => {
  if (!validateUser(req.body)) {
    return res.status(400).json({ errors: validateUser.errors });
  }

  // req.body is now guaranteed safe
  const user = { id: 1, ...req.body };
  res.json(user);
});

app.listen(3000);

Dependency Audit Automation

Scan dependencies for vulnerabilities in CI/CD.

# Manual audit
npm audit

# Audit in CI
npm audit --audit-level=moderate

# Fix vulnerabilities
npm audit fix

# Dry run
npm audit fix --dry-run

# Generate report
npm audit --json > audit-report.json

GitHub workflow:

name: Security Audit
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  schedule:
    - cron: '0 0 * * 0' # Weekly

jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: '18'

      - name: Install dependencies
        run: npm ci

      - name: Audit dependencies
        run: npm audit --audit-level=moderate

      - name: Security scan with SNYK
        uses: snyk/actions/node@master
        env:
          SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}

Or use Dependabot (GitHub native):

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: npm
    directory: '/'
    schedule:
      interval: daily
    open-pull-requests-limit: 10

Process Privilege Dropping

Run with minimal privileges, drop to non-root user.

# Dockerfile
FROM node:18-alpine

WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

COPY . .

# Create non-root user
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nodejs -u 1001

# Change ownership
RUN chown -R nodejs:nodejs /app

# Switch to non-root
USER nodejs

EXPOSE 3000
CMD ["node", "server.ts"]
// server.ts: verify running as non-root
import process from 'process';

if (process.getuid?.() === 0) {
  console.error('ERROR: Running as root is dangerous!');
  process.exit(1);
}

import express from 'express';

const app = express();

app.get('/info', (req, res) => {
  res.json({
    uid: process.getuid?.(),
    gid: process.getgid?.(),
    user: process.env.USER,
  });
});

app.listen(3000);

Linux deployment:

# Create non-root user
useradd -r -s /bin/false appuser

# Start with restrictions
sudo -u appuser node server.ts

# Or use systemd service
# /etc/systemd/system/app.service
[Service]
User=appuser
Group=appuser
ExecStart=/usr/bin/node /app/server.ts
ProtectSystem=strict
ProtectHome=yes
NoNewPrivileges=yes

Environment Variable Leakage Prevention

Never log or expose secrets.

import express from 'express';

const app = express();

// DANGEROUS: exposes all env vars
app.get('/config', (req, res) => {
  res.json(process.env); // NEVER DO THIS
});

// DANGEROUS: logs secrets
console.log('Database URL:', process.env.DATABASE_URL);

// CORRECT: whitelist safe variables
const SAFE_ENV = [
  'NODE_ENV',
  'LOG_LEVEL',
  'PORT',
];

app.get('/config', (req, res) => {
  const safe: Record<string, string> = {};

  for (const key of SAFE_ENV) {
    if (key in process.env) {
      safe[key] = process.env[key]!;
    }
  }

  res.json(safe);
});

// CORRECT: use secure config library
import dotenv from 'dotenv';

dotenv.config();

const config = {
  port: parseInt(process.env.PORT || '3000'),
  nodeEnv: process.env.NODE_ENV || 'development',
  // Don't export secrets
};

// Log safely
console.log('Config:', {
  port: config.port,
  environment: config.nodeEnv,
  // NOT including DATABASE_URL, API_KEYS, etc.
});

// Error handling: don't expose stack traces to clients
app.use((err: any, req: any, res: any, next: any) => {
  console.error('Error:', err); // Log full error

  // Send sanitized response
  res.status(500).json({
    error: 'Internal server error',
    // NOT including err.message or err.stack
  });
});

app.listen(3000);

--frozen-intrinsics Flag

Prevent modification of built-in objects (defense against some exploits).

# Start with frozen intrinsics
node --frozen-intrinsics server.ts

This prevents attacks that modify Object, Array, etc:

// With --frozen-intrinsics, this throws
try {
  Object.defineProperty(Object.prototype, 'isAdmin', {
    value: true,
  });
  // TypeError: Cannot add property isAdmin, object is not extensible
} catch (err) {
  console.log('Protected!', err.message);
}

// Normal mode: silently fails or modifies (depending on strict mode)
// With --frozen-intrinsics: throws error

Use in production:

# Dockerfile
FROM node:18-alpine

WORKDIR /app
COPY . .

# Run with frozen intrinsics
CMD ["node", "--frozen-intrinsics", "server.ts"]

Checklist

  • ✓ Install and configure Helmet.js with CSP, HSTS, frameguard
  • ✓ Implement rate limiting (express-rate-limit with Redis)
  • ✓ Always use parameterized queries (never string concatenation)
  • ✓ Validate input with JSON Schema, allowlist properties
  • ✓ Prevent prototype pollution: whitelist properties or use JSON schema
  • ✓ Run npm audit in CI/CD on every build
  • ✓ Drop privileges: run as non-root user (UID > 0)
  • ✓ Never log or expose environment variables
  • ✓ Sanitize error responses: don't leak stack traces
  • ✓ Use --frozen-intrinsics to prevent intrinsic modification
  • ✓ Add security headers: CSP, HSTS, X-Frame-Options

Conclusion

Security isn't a feature—it's infrastructure. These 10 patterns prevent 90% of attacks. Layer them: Helmet headers, rate limiting, input validation, dependency auditing, privilege dropping. No single layer is bulletproof, but together they form a formidable defense.