The Security Mindset
Security is not a feature β itβs a practice. Every request that reaches your server is a potential attack vector.
Attacker Your Server
β β
βββ SQL Injection βββββββββββββΊβ Validate input
βββ XSS βββββββββββββββββββββββΊβ Escape output
βββ Rate Limit Bypass βββββββββΊβ Rate limiting
βββ DoS / Memory Exhaust ββββββΊβ Body size limits
βββ Path Traversal ββββββββββββΊβ Restrict file access
βββ CORS Abuse ββββββββββββββββΊβ Restrict origins
βββ Dependency Exploit ββββββββΊβ Regular audits
1. HTTP Security Headers with Helmet
helmet sets security-related HTTP headers that protect against common attacks:
npm install helmet
const express = require('express');
const helmet = require('helmet');
const app = express();
// Apply all default helmet middleware
app.use(helmet());
// Or customise:
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "https://trusted-cdn.com"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https://images.example.com"],
},
},
crossOriginEmbedderPolicy: false,
crossOriginResourcePolicy: { policy: 'cross-origin' },
}));
What Helmet Headers Do
| Header | Purpose |
|---|---|
Content-Security-Policy | Prevents XSS β controls allowed sources for scripts, styles, images |
X-Content-Type-Options: nosniff | Prevents MIME type sniffing |
X-Frame-Options: DENY | Prevents clickjacking (page in iframe) |
X-XSS-Protection: 0 | Disables legacy XSS filter (modern CSP replaces it) |
Strict-Transport-Security | Enforces HTTPS for a year |
Referrer-Policy: strict-origin-when-cross-origin | Controls referrer header |
2. Rate Limiting
Prevents brute-force attacks and DoS by limiting requests per IP:
npm install express-rate-limit
const rateLimit = require('express-rate-limit');
// Global rate limiter β 100 requests per minute per IP
const globalLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute window
max: 100, // 100 requests per window
standardHeaders: true, // Return rate limit info in headers
legacyHeaders: false,
message: {
error: 'Too many requests, please try again later.',
},
});
app.use(globalLimiter);
// Strict rate limiter for auth endpoints
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts per window
skipSuccessfulRequests: true, // Only count failed attempts
message: {
error: 'Too many login attempts. Try again in 15 minutes.',
},
});
app.use('/api/auth/login', authLimiter);
// Rate limiter for API endpoints
const apiLimiter = rateLimit({
windowMs: 60 * 1000,
max: 60,
message: { error: 'API rate limit exceeded' },
});
app.use('/api/', apiLimiter);
Rate Limiting with Redis (for multi-server deployments)
const RedisStore = require('rate-limit-redis');
const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);
const limiter = rateLimit({
store: new RedisStore({
sendCommand: (...args) => redis.call(...args),
}),
windowMs: 60 * 1000,
max: 100,
});
3. Input Validation
Never trust user input. Validate everything at the boundary:
// Manual validation
function validateUserInput(body) {
const errors = {};
// Email
if (!body.email || typeof body.email !== 'string') {
errors.email = 'Email is required';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) {
errors.email = 'Invalid email format';
}
// Age β must be integer between 0 and 150
if (body.age !== undefined) {
const age = parseInt(body.age, 10);
if (isNaN(age) || age < 0 || age > 150) {
errors.age = 'Age must be between 0 and 150';
}
}
// URL β must be valid and use HTTPS
if (body.website) {
try {
const url = new URL(body.website);
if (url.protocol !== 'https:') {
errors.website = 'Website must use HTTPS';
}
} catch {
errors.website = 'Invalid URL format';
}
}
return {
valid: Object.keys(errors).length === 0,
errors,
sanitized: {
email: body.email?.trim().toLowerCase(),
age: parseInt(body.age, 10),
website: body.website?.trim(),
},
};
}
Using zod for Validation (Recommended)
npm install zod
const { z } = require('zod');
// Define a schema
const UserSchema = z.object({
email: z.string().email('Invalid email address'),
age: z.number().int().min(0).max(150).optional(),
website: z.string().url('Invalid URL').optional(),
role: z.enum(['user', 'admin', 'moderator']).default('user'),
tags: z.array(z.string().max(50)).max(10).optional(),
});
// Validate
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.flatten().fieldErrors,
});
}
// result.data is fully typed and sanitised
console.log(result.data.email); // string
console.log(result.data.role); // 'user' | 'admin' | 'moderator'
// ...
});
SQL Injection Protection
// β BAD β string interpolation
const query = `SELECT * FROM users WHERE id = ${req.params.id}`;
// β
GOOD β parameterised query
const query = 'SELECT * FROM users WHERE id = $1';
db.query(query, [req.params.id]);
// β
GOOD β ORM abstraction
const user = await User.findByPk(req.params.id);
4. CORS (Cross-Origin Resource Sharing)
Restrict which domains can access your API:
npm install cors
const cors = require('cors');
// Development β allow all origins
if (process.env.NODE_ENV === 'development') {
app.use(cors());
}
// Production β restrict to specific origins
if (process.env.NODE_ENV === 'production') {
const allowedOrigins = process.env.CORS_ORIGIN?.split(',') || [];
app.use(cors({
origin: (origin, callback) => {
// Allow requests with no origin (server-to-server, curl, etc.)
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, // Allow cookies/auth headers
maxAge: 86400, // Cache preflight for 24 hours
}));
}
5. Body Size Limits
Prevent memory exhaustion attacks:
const express = require('express');
const app = express();
// Limit JSON body to 100KB
app.use(express.json({ limit: '100kb' }));
// Limit URL-encoded bodies (forms)
app.use(express.urlencoded({ extended: true, limit: '100kb' }));
// Raw body limit (for webhooks)
app.use(express.raw({ type: 'application/octet-stream', limit: '10mb' }));
For the raw http module:
const MAX_BODY_SIZE = 100 * 1024; // 100KB
function parseBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
let totalSize = 0;
req.on('data', (chunk) => {
totalSize += chunk.length;
if (totalSize > MAX_BODY_SIZE) {
reject(new Error('Request body too large'));
req.destroy(); // Close connection immediately
return;
}
chunks.push(chunk);
});
req.on('end', () => resolve(Buffer.concat(chunks).toString()));
req.on('error', reject);
});
}
6. Path Traversal Protection
When serving files, never trust user-supplied paths:
const path = require('path');
const fs = require('fs');
// β BAD β path traversal
app.get('/files/:name', (req, res) => {
const filePath = path.join('/var/www/files', req.params.name);
// Attacker: GET /files/../../../etc/passwd β /var/www/files/../../../etc/passwd
});
// β
GOOD β restrict to allowed directory
const ALLOWED_DIR = path.resolve('/var/www/files');
app.get('/files/:name', (req, res) => {
// Sanitise: remove path separators and '..'
const safeName = path.basename(req.params.name); // Only the filename
const filePath = path.join(ALLOWED_DIR, safeName);
// Double-check: ensure resolved path is within allowed directory
if (!filePath.startsWith(ALLOWED_DIR)) {
return res.status(403).json({ error: 'Access denied' });
}
res.sendFile(filePath);
});
7. CSRF Protection (for Cookie-Based Auth)
npm install csurf
const csrf = require('csurf');
const cookieParser = require('cookie-parser');
app.use(cookieParser());
app.use(csrf({ cookie: true }));
// Send CSRF token with forms
app.get('/form', (req, res) => {
res.render('form', { csrfToken: req.csrfToken() });
});
8. Dependency Auditing
Regularly check for known vulnerabilities in your dependencies:
# npm audit
npm audit # Check for vulnerabilities
npm audit fix # Auto-fix patchable vulnerabilities
npm audit fix --force # Force major version upgrades
# Snyk (more comprehensive)
npm install -g snyk
snyk test # Full dependency scan
snyk monitor # Continuous monitoring
# Socket.dev (blocks malicious packages)
npm install -g @socketsecurity/cli
socket scan
Automate in CI/CD
# .github/workflows/security.yml
name: Security audit
on:
schedule:
- cron: '0 0 * * 0' # Weekly
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm audit
9. Secrets and Config Security
// β BAD β hardcoded secrets
const jwtSecret = 'my-super-secret';
const dbPassword = 'password123';
// β
GOOD β environment variables
const jwtSecret = process.env.JWT_SECRET; // Required
const dbPassword = process.env.DB_PASSWORD; // Required
// β BAD β committing .env
// .env should be in .gitignore!
// β
GOOD β .env.example documents the structure
10. HTTPS Redirect
// Force HTTPS in production
if (process.env.NODE_ENV === 'production') {
app.use((req, res, next) => {
if (req.headers['x-forwarded-proto'] !== 'https') {
return res.redirect(`https://${req.headers.host}${req.url}`);
}
next();
});
}
Security Checklist
| Check | Tool/Practice | Done? |
|---|---|---|
| HTTP security headers | helmet middleware | β |
| Rate limiting | express-rate-limit | β |
| Input validation | zod or manual validation | β |
| Parameterised queries | No string interpolation in SQL | β |
| CORS restricted | Specific origins, not * | β |
| Body size limits | express.json({ limit: '100kb' }) | β |
| Path traversal protection | path.basename() + prefix check | β |
| CSRF tokens | For cookie-based auth | β |
| No secrets in code | All secrets in env vars | β |
| HTTPS enforced | Redirect HTTP β HTTPS | β |
| npm audit | Run regularly, fix critical | β |
| Error info hidden in prod | No stack traces in responses | β |
| Session security | httpOnly, secure, sameSite cookies | β |
Key Takeaways
- Helmet adds critical HTTP security headers (CSP, HSTS, X-Frame-Options)
- Rate limiting prevents brute-force and DoS β use different limits for auth vs general API
- Validate all input at the boundary with
zodor manual validation β never trust user data - Use parameterised queries β never interpolate values into SQL strings
- Restrict CORS to specific origins in production β never use
*for credentialed requests - Limit body sizes β a 100KB JSON limit prevents memory exhaustion attacks
- Prevent path traversal β use
path.basename()and check that the resolved path is within the allowed directory - Audit dependencies regularly β
npm auditweekly, consider Snyk for continuous monitoring - Never hardcode secrets β all configuration and secrets come from environment variables
- Hide error details in production β log stack traces internally, return safe messages to clients