Security is Not Optional
Every Node.js application that handles user data or is accessible over the network must be secured. Security isn't something you add later; it must be baked into your development process from day one.
🛡️ OWASP Top Risks for Node.js
Injection
SQL, NoSQL, command injection via unsanitized input
Broken Auth
Weak passwords, missing rate limits, insecure tokens
XSS
Cross-site scripting through unescaped user content
Dependency Vulns
Exploits in third-party packages
Input Validation with Zod
Never trust user input. Validate and sanitize everything:
const { z } = require('zod');
// Define strict schemas
const UserSchema = z.object({
name: z.string().min(2).max(100).trim(),
email: z.string().email().toLowerCase(),
age: z.number().int().min(13).max(150),
role: z.enum(['user', 'admin', 'moderator']),
bio: z.string().max(500).optional(),
});
// Validate in route handler
app.post('/api/users', (req, res) => {
const result = UserSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: 'Validation failed',
details: result.error.issues.map(i => ({
field: i.path.join('.'),
message: i.message
}))
});
}
// result.data is typed and sanitized
const user = createUser(result.data);
res.json(user);
});
// Validate query params
const SearchSchema = z.object({
q: z.string().min(1).max(200),
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
app.get('/api/search', (req, res) => {
const params = SearchSchema.parse(req.query);
// params.q, params.page, params.limit are safe
});
Preventing Injection Attacks
// SQL Injection Prevention
// BAD: String concatenation
const bad = `SELECT * FROM users WHERE id = '${userId}'`;
// GOOD: Parameterized queries
const { rows } = await pool.query(
'SELECT * FROM users WHERE id = $1 AND role = $2',
[userId, role]
);
// NoSQL Injection Prevention (MongoDB)
// BAD: Direct user input in query
const bad2 = await db.users.find({ username: req.body.username });
// Attacker sends: { "username": { "$gt": "" } } -> returns all users
// GOOD: Validate types before querying
const username = String(req.body.username); // Force to string
const user = await db.users.findOne({ username });
// Command Injection Prevention
const { execFile } = require('child_process');
// BAD: Shell injection via exec
const bad3 = exec(`convert ${userInput}.png output.jpg`);
// Attacker sends: "; rm -rf /" as userInput
// GOOD: execFile doesn't use shell
execFile('convert', [userInput + '.png', 'output.jpg'], (err, stdout) => {
// Safe: arguments are passed as array, not shell string
});
// Path Traversal Prevention
const path = require('path');
function safeFilePath(baseDir, userInput) {
const resolved = path.resolve(baseDir, userInput);
if (!resolved.startsWith(path.resolve(baseDir))) {
throw new Error('Path traversal detected');
}
return resolved;
}
// Attacker sends: "../../etc/passwd"
safeFilePath('/uploads', '../../etc/passwd'); // Throws error
Rate Limiting
// Simple in-memory rate limiter
class RateLimiter {
constructor(windowMs, maxRequests) {
this.windowMs = windowMs;
this.maxRequests = maxRequests;
this.clients = new Map();
}
isAllowed(clientId) {
const now = Date.now();
const client = this.clients.get(clientId) || { count: 0, resetAt: now + this.windowMs };
// Reset window if expired
if (now > client.resetAt) {
client.count = 0;
client.resetAt = now + this.windowMs;
}
client.count++;
this.clients.set(clientId, client);
return {
allowed: client.count <= this.maxRequests,
remaining: Math.max(0, this.maxRequests - client.count),
resetAt: client.resetAt
};
}
}
// Usage as middleware
const limiter = new RateLimiter(15 * 60 * 1000, 100); // 100 requests per 15 min
function rateLimitMiddleware(req, res, next) {
const clientIP = req.ip || req.socket.remoteAddress;
const { allowed, remaining, resetAt } = limiter.isAllowed(clientIP);
res.setHeader('X-RateLimit-Limit', '100');
res.setHeader('X-RateLimit-Remaining', remaining);
res.setHeader('X-RateLimit-Reset', Math.ceil(resetAt / 1000));
if (!allowed) {
return res.status(429).json({ error: 'Too many requests' });
}
next();
}
// For production, use Redis-backed rate limiting:
// npm install express-rate-limit rate-limit-redis
Secure Headers with Helmet
const helmet = require('helmet');
const express = require('express');
const app = express();
// Helmet sets 15+ security headers with one line
app.use(helmet());
// What helmet does:
// - Content-Security-Policy: Prevents XSS and data injection
// - X-Content-Type-Options: nosniff - Prevents MIME sniffing
// - X-Frame-Options: DENY - Prevents clickjacking
// - Strict-Transport-Security: Forces HTTPS
// - X-XSS-Protection: Legacy XSS filter
// - Referrer-Policy: Controls referrer info
// Custom CSP configuration
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "https://cdn.example.com"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'", "https://api.example.com"],
fontSrc: ["'self'", "https://fonts.googleapis.com"],
objectSrc: ["'none'"],
upgradeInsecureRequests: [],
}
}));
// CSRF Protection
const csrf = require('csurf');
app.use(csrf({ cookie: true }));
app.get('/form', (req, res) => {
// Include CSRF token in forms
res.render('form', { csrfToken: req.csrfToken() });
});
Environment Variables and Secrets
// NEVER hardcode secrets in source code
// BAD:
const apiKey = 'sk-1234567890abcdef';
// GOOD: Use environment variables
const apiKey = process.env.API_KEY;
// Validate required env vars at startup
function validateEnv() {
const required = ['DATABASE_URL', 'JWT_SECRET', 'API_KEY'];
const missing = required.filter(key => !process.env[key]);
if (missing.length > 0) {
console.error('Missing environment variables:', missing.join(', '));
process.exit(1);
}
}
validateEnv();
// .env file for local development (never commit!)
// npm install dotenv
require('dotenv').config();
// .env
// DATABASE_URL=postgresql://user:pass@localhost/db
// JWT_SECRET=your-secret-here
// API_KEY=sk-1234567890abcdef
// .gitignore:
// .env
// .env.local
// .env.production
// Dependency scanning
// npm audit # Check for known vulnerabilities
// npx snyk test # Deep vulnerability scan
// npx socket-security npm audit # Supply chain attack detection
💡 Key Takeaways
- • Validate all input with schemas (Zod, Joi)
- • Always use parameterized queries to prevent SQL injection
- • Implement rate limiting on all public endpoints
- • Use
helmetfor security headers in one line - • Never hardcode secrets; validate env vars at startup