Node.js API Security Hardening: The Checklist That Closes the Gaps Tutorials Leave Open

Helmet, CORS lockdown, parameter pollution, timing-safe comparisons, secrets that never touch your codebase, and a 30-point audit checklist

ยท
6 min read
Node.js API Security Hardening: The Checklist That Closes the Gaps Tutorials Leave Open

Most Node.js security tutorials cover the obvious things โ€” use HTTPS, hash passwords, validate input. The gaps they leave are the ones attackers exploit.

This guide covers the security measures that go beyond the basics: HTTP security headers, dependency vulnerability management, secrets that never touch your codebase, the specific Express configurations that prevent common attacks, and the audit that tells you where your current API is exposed.


1 โ€” HTTP Security Headers with Helmet

One middleware, eleven attack vectors closed. Helmet sets HTTP response headers that tell browsers how to handle your content.

npm install helmet
import helmet from 'helmet';

// Default helmet configuration is a strong starting point
app.use(helmet());

// Or configure explicitly for your specific needs
app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc:   ["'self'"],
      scriptSrc:    ["'self'"],           // No inline scripts
      styleSrc:     ["'self'", "'unsafe-inline'"],
      imgSrc:       ["'self'", 'data:', 'https:'],
      connectSrc:   ["'self'"],
      fontSrc:      ["'self'"],
      objectSrc:    ["'none'"],
      frameSrc:     ["'none'"],
      upgradeInsecureRequests: [],
    },
  },
  crossOriginEmbedderPolicy: false,        // Disable if embedding third-party content
  hsts: {
    maxAge:            31536000,           // 1 year
    includeSubDomains: true,
    preload:           true,
  },
  referrerPolicy:    { policy: 'strict-origin-when-cross-origin' },
  frameguard:        { action: 'deny' },   // No iframes โ€” prevents clickjacking
  noSniff:           true,                 // X-Content-Type-Options: nosniff
  xssFilter:         true,                 // Legacy XSS protection header
}));

What each one prevents:

  • contentSecurityPolicy โ€” XSS by restricting where scripts can load from

  • hsts โ€” SSL stripping attacks

  • frameguard โ€” clickjacking

  • noSniff โ€” MIME-type confusion attacks

  • referrerPolicy โ€” referrer information leaking to external sites


2 โ€” CORS Configuration

Wildcard CORS (*) is almost never the right answer for an API with authenticated routes. Lock it down:

import cors from 'cors';

const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS?.split(',') || [
  'https://yourapp.com',
  'https://www.yourapp.com',
];

// Add localhost only in development
if (process.env.NODE_ENV === 'development') {
  ALLOWED_ORIGINS.push('http://localhost:3000', 'http://localhost:5173');
}

app.use(cors({
  origin: (origin, callback) => {
    // Allow requests with no origin (mobile apps, Postman, server-to-server)
    if (!origin) return callback(null, true);

    if (ALLOWED_ORIGINS.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error(`Origin ${origin} not allowed by CORS`));
    }
  },
  credentials: true,          // Required for cookies (refresh tokens)
  methods:     ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
  allowedHeaders: [
    'Content-Type',
    'Authorization',
    'X-Request-ID',
  ],
  maxAge: 86400,              // Cache preflight for 24 hours
}));

3 โ€” Request Size Limits

Without size limits, a single request with a 1GB body can exhaust your server's memory.

// Limit JSON body size
app.use(express.json({ limit: '100kb' }));
app.use(express.urlencoded({ extended: true, limit: '100kb' }));

// Different limit for file upload endpoints
app.use('/api/upload', express.raw({ limit: '10mb' }));

// Limit URL length (default is unlimited in Express)
app.use((req, res, next) => {
  if (req.url.length > 2048) {
    return res.status(414).json({ error: 'URL too long' });
  }
  next();
});

4 โ€” Parameter Pollution Prevention

HTTP allows the same query parameter multiple times: ?sort=asc&sort=desc. If your code does req.query.sort expecting a string and gets an array, it can cause unexpected behavior or crashes.

npm install hpp
import hpp from 'hpp';

// Prevents HTTP Parameter Pollution
// Takes the last occurrence of duplicate params by default
app.use(hpp({
  whitelist: ['tags', 'categories'],  // Allow arrays for these specific params
}));

5 โ€” Secrets Management

Every secret that appears in your codebase or .env committed to git is a potential breach. The right pattern depends on your environment.

Never do:

// Hard-coded โ€” rotatable only by code change
const API_KEY = 'sk_live_abc123';

// In git history even if deleted later
// .env files committed to the repo

For VPS deployments:

# On the server โ€” set via SSH, not through CI
echo "JWT_PRIVATE_KEY=$(cat private.pem)" >> /opt/app/.env.production
chmod 600 /opt/app/.env.production
chown deploy:deploy /opt/app/.env.production

For Docker โ€” use secrets, not environment variables:

# docker-compose.yml
secrets:
  jwt_private_key:
    file: ./secrets/jwt_private_key.pem

services:
  app:
    secrets:
      - jwt_private_key
    environment:
      # Reference the file path, not the secret value
      JWT_PRIVATE_KEY_FILE: /run/secrets/jwt_private_key
// Read from file if available, fall back to env
function getSecret(name: string): string {
  const fileVar = process.env[`${name}_FILE`];
  if (fileVar) {
    return fs.readFileSync(fileVar, 'utf-8').trim();
  }
  const value = process.env[name];
  if (!value) throw new Error(`Secret ${name} not configured`);
  return value;
}

const JWT_PRIVATE_KEY = getSecret('JWT_PRIVATE_KEY');

Scan for accidental secret commits:

# Add to your CI pipeline โ€” runs before every merge
npx secretlint "**/*"

# Or use git-secrets pre-commit hook
git secrets --install
git secrets --register-aws

6 โ€” SQL Injection: Never Use String Interpolation

Every SQL query with user input must use parameterized queries. No exceptions.

// VULNERABLE โ€” string interpolation in SQL
const result = await db.query(
  `SELECT * FROM users WHERE email = '${email}'`
);
// Input "' OR '1'='1" returns all users

// SAFE โ€” parameterized query
const result = await db.query(
  'SELECT * FROM users WHERE email = $1',
  [email]
);

With an ORM like Drizzle, parameterization is handled automatically. The risk is in raw SQL strings โ€” audit every db.query() call that includes a variable.


7 โ€” Dependency Vulnerability Management

Outdated dependencies are one of the most common real-world attack vectors. Automate the scanning:

# Run locally
npm audit

# Fix automatically where possible
npm audit fix

# Check for outdated packages
npm outdated

Add to CI โ€” fail the build on high-severity vulnerabilities:

# .github/workflows/ci.yml
- name: Security audit
  run: npm audit --audit-level=high

Enable Dependabot for automated PRs:

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: npm
    directory: /
    schedule:
      interval: weekly
    open-pull-requests-limit: 10
    ignore:
      - dependency-name: "*"
        update-types: ["version-update:semver-major"]  # Major versions need manual review

8 โ€” Timing Attack Prevention

String comparison using === is not constant-time. An attacker can measure response times to determine how many characters of a token or HMAC they got right.

import { timingSafeEqual, createHmac } from 'crypto';

// BAD โ€” vulnerable to timing attack
function verifyToken(provided: string, expected: string): boolean {
  return provided === expected;
}

// GOOD โ€” constant-time comparison
function verifyToken(provided: string, expected: string): boolean {
  // Buffers must be same length for timingSafeEqual
  try {
    return timingSafeEqual(
      Buffer.from(provided),
      Buffer.from(expected)
    );
  } catch {
    return false;  // Different lengths โ€” definitely not equal
  }
}

This matters for: webhook signature verification, API key comparison, HMAC verification, and password reset token comparison.


9 โ€” Express Configuration Hardening

Several Express defaults expose information attackers can use:

// Remove X-Powered-By header โ€” tells attackers you're using Express
app.disable('x-powered-by');
// (Helmet does this too, but belt-and-suspenders)

// Trust proxy โ€” required if behind Nginx/load balancer
// Without this, req.ip returns the proxy's IP, not the client's
// Rate limiting by IP breaks without this
app.set('trust proxy', 1);   // Trust one proxy level

// Disable etag for API responses โ€” prevents cache-related issues
app.set('etag', false);

// Set secure cookie defaults
app.use((req, res, next) => {
  res.cookie = new Proxy(res.cookie, {
    apply(target, thisArg, args) {
      const [name, value, options = {}] = args;
      if (process.env.NODE_ENV === 'production') {
        options.secure   = options.secure ?? true;
        options.httpOnly = options.httpOnly ?? true;
        options.sameSite = options.sameSite ?? 'strict';
      }
      return target.call(thisArg, name, value, options);
    }
  });
  next();
});

10 โ€” The Security Audit Checklist

Run through this before every major release:

Authentication & Authorization
โœ… Passwords hashed with bcrypt (cost factor >= 12)
โœ… JWT tokens use RS256, not HS256
โœ… Access tokens expire in <= 15 minutes
โœ… Refresh tokens stored as hashes in DB
โœ… Every authenticated route actually calls authenticate()
โœ… Every tenant-scoped query includes tenant_id

Input & Output
โœ… All user input validated with Zod before use
โœ… All SQL queries use parameterized syntax โ€” no string interpolation
โœ… File uploads validate MIME type server-side
โœ… Response never includes password_hash or internal fields

Network & Headers
โœ… Helmet middleware installed and configured
โœ… CORS locked to specific origins
โœ… Request body size limits set
โœ… HPP middleware installed
โœ… HTTPS enforced (HSTS header set)

Secrets & Config
โœ… No secrets in codebase or git history
โœ… No secrets in environment variables in CI logs
โœ… .env not committed to git
โœ… npm audit passes at high severity level

Dependencies
โœ… Dependabot enabled
โœ… No packages with known critical CVEs
โœ… Node.js version is current LTS

Infrastructure
โœ… Database not exposed to internet (internal network only)
โœ… Redis not exposed to internet
โœ… SSH password authentication disabled
โœ… UFW firewall configured

Running a Quick Security Scan

# Dependency vulnerabilities
npm audit --audit-level=moderate

# Check for exposed secrets in git history
npx git-secrets --scan-history

# Static analysis for common security issues
npx eslint --plugin security src/

# Check HTTP headers in production
curl -I https://yourapi.com/api/health | grep -E "X-Frame|Content-Security|Strict-Transport|X-Content"

Security is not a feature you add at the end. Every item on this checklist is a decision made (or deferred) during implementation. The deferred ones accumulate silently until an incident makes them visible.

Comments(0)

Join the discussion by logging into your account.

No comments yet. Be the first to comment!

ZyVOP

Founder of Zyvop ๐Ÿš€ | Building AI-driven tools & premium insights for software engineers, CTOs, and tech leaders. Obsessed with automating workflows and exploring the frontier of AI.

Subscribe to ZyVOP's Newsletter

Direct email dispatches when new stories are published. Zero algorithms.