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
Senior Developer

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 helmetimport 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 fromhsts— SSL stripping attacksframeguard— clickjackingnoSniff— MIME-type confusion attacksreferrerPolicy— 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 hppimport 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 repoFor 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.productionFor 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-aws6 — 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 outdatedAdd to CI — fail the build on high-severity vulnerabilities:
# .github/workflows/ci.yml
- name: Security audit
run: npm audit --audit-level=highEnable 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 review8 — 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 configuredRunning 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.