ZyVOP Logo
Content That Connects
SeriesCategoriesTags
ZyVOP Logo
Content That Connects

Empowering developers and creators with cutting-edge insights, comprehensive tutorials, and innovative solutions for the digital future.

Content

  • Tags
  • Write Article

Company

  • About Us
  • Contact

Connect

  • Privacy Policy
  • Terms of Service
  • Cookie Policy
  • DMCA Policy
  • Code of Conduct

© 2026 ZyVOP. Crafted with care for the developer community.

Made with ❤️ by the ZyVOP team
All systems operational
HomeNode.js API Security Hardening: The Checklist That Closes the Gaps Tutorials Leave Open

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

#Node.js API security#Express security headers Helmet#Node.js security checklist 2026#CORS configuration Express#SQL injection prevention#timing attack Node.js#dependency vulnerabilities Node.js#secrets management Node.js
Z
ZyVOP

Senior Developer

May 27, 2026
8 min read
3 views
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.

Z

ZyVOP

Passionate developer sharing knowledge about modern web technologies and best practices.

Comments (0)

Login to post a comment.

Stay Updated

Get the latest articles delivered to your inbox.

We respect your privacy. Unsubscribe anytime.

Popular Tags

#.env.example Node.js#0x profiling#12-factor#AI agents#AI code security#AI coding tools 2026#AI-assisted development#AI-generated vulnerabilities#ALTER TABLE no lock#API Design