ZyVOP Logo
Content That Connects
SeriesAI NewsWhy ZyVOPJoin Discord
LoginGet Started
ZyVOP Logo
Content That Connects

The Developer Publishing Hub. Write once, cross-post to Dev.to, Medium, Hashnode, WordPress & Bluesky with automated canonical source tags and zero paywalls.

Content

  • Categories
  • Tags
  • Badges
  • Leaderboard
  • Write Article
  • Newsletter

Company

  • About Us
  • Why ZyVOP
  • Developer API & CLI
  • Write for Us
  • Contact

Connect

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

ยฉ 2026 ZyVOP. Developer Publishing Hub.

Zero paywalls ยท Full content ownership
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

ZyVOP
ZyVOP
Senior Developer
May 27, 2026
6 min read
Node.js API Security Hardening: The Checklist That Closes the Gaps Tutorials Leave Open
#SQL injection prevention#Node.js security checklist 2026#Express security headers Helmet#dependency vulnerabilities Node.js#timing attack Node.js#secrets management Node.js#CORS configuration Express#Node.js API security

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)

Login to post a comment.

ZyVOP
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

More from ZyVOP

View profile

Debian Adopts "Responsible Use of Generative AI" After Nine-Way Condorcet Vote

Debian's General Resolution 2026-002 closed on August 28 with "Responsible Use of Generative AI" beating eight rival proposals, including a Social Contract ban, by a clear Condorcet margin, per the project secretary's published beat matrix.

3 minAug 30

How I Built a Real-Time Developer Trend Radar Into My SEO Growth Engine

An AI-powered content intelligence system that streams live developer conversations from Hacker News, Dev.to, Google Search, and GitHub โ€” and turns them into ready-to-write blog opportunities with one click.

12 minAug 29

Qwen3.8-Flash-Next Cost Efficiency, OpenExecutive Satire, and Multi-Vector Retrieval Advances

This week's digest covers Qwen3.8-Flash-Next's push for ultimate cost-efficiency, the viral OpenExecutive project, and the technical release of MultiVectorEncoder in Sentence-Transformers v6.0.

4 minAug 28

Anthropicโ€™s Pricing Shock, Granite 4.2 Openโ€‘Source Leap, and AIโ€‘Powered Security & Policy Shifts

From Anthropicโ€™s flagship model losing steam to IBMโ€™s 512โ€ฏKโ€‘token Graniteโ€ฏ4.2, plus a new wave of AIโ€‘driven security exploits and policy alarms, this weekโ€™s digest maps the technical and market forces you need to act on now.

3 minAug 26

Introducing Questions and Discussions: A New Way to Connect!

We are thrilled to announce a major update to how you can interact and share content on our platform! Up until now, sharing your thoughts meant writing a standa...

2 minAug 1