Documentation Index
Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt
Use this file to discover all available pages before exploring further.
Security Best Practices
Security isn’t optional—it’s a fundamental requirement for any production application. This chapter covers essential security practices every Node.js developer must implement.
OWASP Top 10 for Node.js
| Vulnerability | Description | Prevention |
|---|
| Injection | SQL, NoSQL, Command injection | Input validation, parameterized queries |
| Broken Auth | Weak passwords, session hijacking | Strong auth, JWT best practices |
| Sensitive Data Exposure | Unencrypted data | HTTPS, encryption at rest |
| XXE | XML External Entities | Disable XML parsing or use safe parsers |
| Broken Access Control | Privilege escalation | RBAC, resource ownership validation |
| Security Misconfiguration | Default credentials, verbose errors | Secure defaults, minimal information |
| XSS | Cross-Site Scripting | Output encoding, CSP headers |
| Insecure Deserialization | Object manipulation | Input validation, schema enforcement |
| Vulnerable Components | Outdated dependencies | Regular updates, npm audit |
| Insufficient Logging | No audit trail | Comprehensive logging and monitoring |
Never trust user input. This is the single most important rule in application security. Every piece of data that crosses your API boundary — request bodies, query parameters, URL params, headers, cookies — should be treated as potentially hostile until proven otherwise. Think of it like airport security: every bag gets scanned, no exceptions, regardless of who is carrying it.
Validation checks that the data has the expected shape and values (is this a valid email? is this number within range?). Sanitization transforms data to remove dangerous content (stripping HTML tags, escaping special characters).
const Joi = require('joi');
const validator = require('validator');
const xss = require('xss');
// Joi schema validation
const userSchema = Joi.object({
email: Joi.string().email().required(),
password: Joi.string().min(8).max(128).required(),
name: Joi.string().min(2).max(100).pattern(/^[a-zA-Z\s]+$/).required(),
age: Joi.number().integer().min(18).max(120)
});
// Validation middleware
const validate = (schema) => (req, res, next) => {
const { error, value } = schema.validate(req.body, {
abortEarly: false,
stripUnknown: true // Remove unknown fields
});
if (error) {
return res.status(400).json({
error: 'Validation failed',
details: error.details.map(d => ({
field: d.path.join('.'),
message: d.message
}))
});
}
req.body = value;
next();
};
// XSS prevention
const sanitizeInput = (input) => {
if (typeof input === 'string') {
return xss(input.trim());
}
if (typeof input === 'object') {
const sanitized = {};
for (const key in input) {
sanitized[key] = sanitizeInput(input[key]);
}
return sanitized;
}
return input;
};
// Additional validation
const isValidEmail = (email) => validator.isEmail(email);
const isValidURL = (url) => validator.isURL(url, { require_protocol: true });
const isAlphanumeric = (str) => validator.isAlphanumeric(str);
SQL Injection Prevention
SQL injection is one of the oldest and most devastating attacks in web security. It works by tricking your application into treating user-supplied data as SQL code. If a user submits '; DROP TABLE users; -- as their userId, and you concatenate it directly into a query string, the database executes the destructive command. Real-world SQL injection attacks have exposed millions of user records and caused data breaches at major companies.
// ❌ NEVER do this - vulnerable to SQL injection.
// If userId is "1; DROP TABLE users; --", the database executes that as code.
const query = `SELECT * FROM users WHERE id = ${userId}`;
// ✅ Use parameterized queries with pg
const { Pool } = require('pg');
const pool = new Pool();
const getUser = async (userId) => {
const result = await pool.query(
'SELECT * FROM users WHERE id = $1',
[userId]
);
return result.rows[0];
};
// ✅ With Prisma (automatically safe)
const user = await prisma.user.findUnique({
where: { id: userId }
});
// ✅ With Mongoose (automatically safe with proper usage)
const user = await User.findById(userId);
// ⚠️ Be careful with $where and string-based queries
// ❌ Vulnerable
User.find({ $where: `this.role === '${role}'` });
// ✅ Safe
User.find({ role: role });
NoSQL Injection Prevention
NoSQL injection is subtler than SQL injection but equally dangerous. Instead of injecting SQL syntax, attackers inject MongoDB query operators. For example, if req.body.password is { "$gt": "" } instead of a string, the query User.findOne({ password: req.body.password }) becomes User.findOne({ password: { $gt: "" } }) — which matches every user with a non-empty password. The attacker logs in without knowing any password.
This attack works because Express’s json() parser converts JSON objects in the request body into actual JavaScript objects, and Mongoose happily passes those objects to MongoDB as query operators.
// ❌ Vulnerable to NoSQL injection
// If req.body.password is { $gt: '' }, this matches any user with a password
app.post('/login', async (req, res) => {
const user = await User.findOne({
email: req.body.email,
password: req.body.password // Could be { $gt: '' } -- matches everything!
});
});
// ✅ Validate types before querying
const mongoSanitize = require('express-mongo-sanitize');
app.use(mongoSanitize()); // Remove $ and . from req.body/query/params
// Or manually validate
app.post('/login', async (req, res) => {
const { email, password } = req.body;
if (typeof email !== 'string' || typeof password !== 'string') {
return res.status(400).json({ error: 'Invalid input' });
}
const user = await User.findOne({ email });
if (user && await bcrypt.compare(password, user.password)) {
// Success
}
});
Authentication Security
const bcrypt = require('bcrypt');
const rateLimit = require('express-rate-limit');
// Password hashing
const SALT_ROUNDS = 12; // Increase for more security (slower)
const hashPassword = async (password) => {
return bcrypt.hash(password, SALT_ROUNDS);
};
const verifyPassword = async (password, hash) => {
return bcrypt.compare(password, hash);
};
// Rate limiting for auth endpoints
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts
message: { error: 'Too many login attempts, try again later' },
standardHeaders: true,
legacyHeaders: false
});
app.post('/api/auth/login', authLimiter, async (req, res) => {
// Login logic
});
// Password strength validation
const passwordSchema = Joi.string()
.min(8)
.max(128)
.pattern(/[a-z]/, 'lowercase')
.pattern(/[A-Z]/, 'uppercase')
.pattern(/[0-9]/, 'number')
.pattern(/[!@#$%^&*]/, 'special')
.required();
// Account lockout
const MAX_ATTEMPTS = 5;
const LOCKOUT_TIME = 15 * 60 * 1000; // 15 minutes
const loginAttempts = new Map();
const checkLockout = (email) => {
const attempts = loginAttempts.get(email);
if (!attempts) return false;
if (attempts.count >= MAX_ATTEMPTS) {
if (Date.now() - attempts.lastAttempt < LOCKOUT_TIME) {
return true; // Account is locked
}
loginAttempts.delete(email); // Reset after lockout period
}
return false;
};
HTTP security headers tell browsers how to behave when handling your site’s content. They are like instructions you give to the browser: “do not let other sites embed me in an iframe,” “only load scripts from my domain,” “always use HTTPS.” Without these headers, browsers use their permissive defaults, leaving your users vulnerable to cross-site scripting, clickjacking, and man-in-the-middle attacks.
Helmet is a middleware that sets these headers for you with sensible defaults. One line of code, and you get protection that would otherwise require configuring a dozen headers manually.
const helmet = require('helmet');
app.use(helmet()); // Sets ~15 security headers with safe defaults
// Or configure individually
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
fontSrc: ["'self'", "https://fonts.gstatic.com"],
imgSrc: ["'self'", "data:", "https:"],
scriptSrc: ["'self'"],
connectSrc: ["'self'", "https://api.example.com"],
}
},
crossOriginEmbedderPolicy: true,
crossOriginOpenerPolicy: true,
crossOriginResourcePolicy: { policy: "same-site" },
dnsPrefetchControl: { allow: false },
frameguard: { action: "deny" },
hidePoweredBy: true,
hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
ieNoOpen: true,
noSniff: true,
originAgentCluster: true,
permittedCrossDomainPolicies: { permittedPolicies: "none" },
referrerPolicy: { policy: "strict-origin-when-cross-origin" },
xssFilter: true
}));
CORS Configuration
CORS (Cross-Origin Resource Sharing) is one of the most misunderstood concepts in web security. Here is the mental model: browsers enforce a rule that scripts on example.com cannot make requests to api.otherdomain.com unless api.otherdomain.com explicitly says “I allow requests from example.com.” This prevents malicious sites from using your users’ cookies to call your API on their behalf.
CORS is enforced by the browser, not the server. Your server only sets headers that tell the browser what is allowed. Tools like curl or Postman ignore CORS entirely — it is purely a browser-based security mechanism.
const cors = require('cors');
// ❌ Too permissive -- allows ANY website to make requests to your API
app.use(cors()); // Allows all origins
// ✅ Restrictive configuration
const corsOptions = {
origin: (origin, callback) => {
const allowedOrigins = [
'https://example.com',
'https://app.example.com'
];
// Allow requests with no origin (mobile apps, curl)
if (!origin) return callback(null, true);
if (allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
maxAge: 86400 // Cache preflight for 24 hours
};
app.use(cors(corsOptions));
Environment Variables Security
Environment variables are the standard way to pass secrets (API keys, database passwords, JWT secrets) to your application without hardcoding them in source code. The principle is simple: secrets belong to the environment, not to the codebase. Your code should read them at runtime, and they should never appear in your git history.
If a secret is committed to git even once, consider it compromised — git history is permanent and commonly cloned by many developers. Rotate the secret immediately.
// ✅ Use dotenv for local development
require('dotenv').config();
// ❌ Never commit .env files -- add ALL of these to .gitignore:
// .env
// .env.local
// .env.production
// ✅ Validate required env vars on startup
const requiredEnvVars = [
'NODE_ENV',
'DATABASE_URL',
'JWT_SECRET',
'SESSION_SECRET'
];
const missingVars = requiredEnvVars.filter(v => !process.env[v]);
if (missingVars.length > 0) {
console.error('Missing required environment variables:', missingVars);
process.exit(1);
}
// ✅ Use strong secrets
// Generate with: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
// ✅ Different secrets per environment
// Never use development secrets in production
Dependency Security
# Check for vulnerabilities
npm audit
# Auto-fix vulnerabilities
npm audit fix
# Force major updates (may break things)
npm audit fix --force
# Use in CI/CD
npm audit --audit-level=high
# Alternative: use Snyk
npx snyk test
// package.json - lock engines
{
"engines": {
"node": ">=18.0.0",
"npm": ">=9.0.0"
}
}
File Upload Security
File uploads are one of the most dangerous features you can add to a web application. Without proper validation, an attacker can upload executable scripts, overwrite system files via path traversal (../../etc/passwd), or exhaust disk space with oversized files. Every file upload must be validated for type, size, and content — and the original filename should never be trusted or used directly.
const multer = require('multer');
const path = require('path');
const crypto = require('crypto');
// Secure file upload configuration -- three layers of defense:
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, 'uploads/');
},
filename: (req, file, cb) => {
// Layer 1: Generate a random filename to prevent path traversal attacks
// and overwriting existing files. Never use the original filename directly.
const ext = path.extname(file.originalname);
const randomName = crypto.randomBytes(16).toString('hex');
cb(null, `${randomName}${ext}`);
}
});
const fileFilter = (req, file, cb) => {
// Layer 2: Validate BOTH the MIME type and the file extension.
// Checking only one is not enough -- MIME types can be spoofed,
// and extensions can be misleading. Check both for defense in depth.
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif'];
const allowedExtensions = ['.jpg', '.jpeg', '.png', '.gif'];
const ext = path.extname(file.originalname).toLowerCase();
if (allowedTypes.includes(file.mimetype) && allowedExtensions.includes(ext)) {
cb(null, true);
} else {
cb(new Error('Invalid file type'), false);
}
};
// Layer 3: Enforce size limits to prevent denial-of-service via disk exhaustion
const upload = multer({
storage,
fileFilter,
limits: {
fileSize: 5 * 1024 * 1024, // 5MB max -- adjust based on your use case
files: 1 // Max number of files per request
}
});
// In production, also scan uploaded files for malware using ClamAV
// or a cloud-based scanning service (e.g., VirusTotal API) before
// making them accessible to other users
Secure Cookie Configuration
Cookies are the primary target for session hijacking attacks. A poorly configured cookie can be stolen via cross-site scripting (XSS), sent over unencrypted HTTP, or exploited via cross-site request forgery (CSRF). Each cookie flag below closes one of these attack vectors.
const session = require('express-session');
app.use(session({
secret: process.env.SESSION_SECRET,
name: 'sessionId', // Rename from default 'connect.sid' -- the default name advertises
// that you are using Express, giving attackers free reconnaissance
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production', // Only send cookie over HTTPS (prevents interception)
httpOnly: true, // Not accessible via document.cookie in JavaScript (blocks XSS cookie theft)
sameSite: 'strict', // Only send cookie for same-site requests (blocks CSRF attacks)
maxAge: 24 * 60 * 60 * 1000, // 24 hours -- session expires after this, forcing re-authentication
domain: '.example.com' // For cross-subdomain cookie sharing (if needed)
}
}));
Logging Security Events
const logger = require('./config/logger');
// Log security events
const securityLogger = {
loginSuccess: (userId, ip) => {
logger.info('Login success', { event: 'auth.login.success', userId, ip });
},
loginFailure: (email, ip, reason) => {
logger.warn('Login failure', { event: 'auth.login.failure', email, ip, reason });
},
suspiciousActivity: (details) => {
logger.error('Suspicious activity', { event: 'security.suspicious', ...details });
},
accessDenied: (userId, resource) => {
logger.warn('Access denied', { event: 'auth.access.denied', userId, resource });
}
};
// Middleware to log requests
app.use((req, res, next) => {
res.on('finish', () => {
if (res.statusCode >= 400) {
logger.warn('Request failed', {
method: req.method,
url: req.originalUrl,
status: res.statusCode,
ip: req.ip,
userId: req.user?.id
});
}
});
next();
});
Security Checklist
Before deploying to production, verify:
Summary
- Validate all input - Never trust client data
- Prevent injection - Use parameterized queries
- Secure authentication - Hash passwords, rate limit, lockout
- Set security headers - Use Helmet
- Configure CORS properly - Whitelist origins
- Keep dependencies updated - Run npm audit regularly
- Handle files securely - Validate type, size, scan for malware
- Log security events - Audit trail for incidents
- Use HTTPS everywhere - Encrypt data in transit
- Follow the principle of least privilege - Minimal permissions