- Published on
Node.js Security Hardening — The Production Checklist Most Teams Skip
- Authors

- Name
- Sanjeev Sharma
- @webcoderspeed1
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
- express-rate-limit with Redis Store
- SQL Injection: Prevention in ORMs
- Prototype Pollution Prevention
- Dependency Audit Automation
- Process Privilege Dropping
- Environment Variable Leakage Prevention
- --frozen-intrinsics Flag
- Checklist
- Conclusion
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.