xss library for input sanitization and helmet for security headers including CSP.
const express = require('express');
const xss = require('xss');
const helmet = require('helmet');
const app = express();
app.use(helmet()); // Sets security headers including CSP
// Sanitize user input before rendering
app.post('/comment', (req, res) => {
const safeComment = xss(req.body.comment);
// Store safeComment, not raw input
});
// In templates — always escape output
// EJS: <%= userInput %> (escaped)
// NEVER: <%- userInput %> (unescaped) with untrusted data
Why it matters: XSS is consistently in the OWASP Top 10 and can completely compromise a user's session; a single unsanitized comment field can allow attackers to steal all session cookies from every visitor to the page.
Real applications: User-generated content platforms (forums, chat, blogs) sanitize all stored and rendered content; CSP headers on banking sites prevent injected scripts from exfiltrating credentials to attacker-controlled domains.
Common mistakes: Sanitizing HTML in one place but forgetting to escape the same content when it appears in a JavaScript context (e.g., inline <script> variables) — context-aware escaping is required for each output context (HTML, JS, CSS, URL).
SameSite cookie attribute provides complementary protection by preventing cross-site cookie submission.
const express = require('express');
const crypto = require('crypto');
// Generate CSRF token
function generateToken() {
return crypto.randomBytes(32).toString('hex');
}
// Middleware to set and validate CSRF token
app.use((req, res, next) => {
if (req.method === 'GET') {
req.session.csrfToken = generateToken();
res.locals.csrfToken = req.session.csrfToken;
} else {
const token = req.body._csrf || req.headers['x-csrf-token'];
if (token !== req.session.csrfToken) {
return res.status(403).json({ error: 'Invalid CSRF token' });
}
}
next();
});
// In forms: <input type="hidden" name="_csrf" value="<%= csrfToken %>">
Why it matters: CSRF can force logged-in users to transfer money, change their email/password, or perform any action the application supports — without any interaction beyond visiting a malicious page.
Real applications: Banking and payment forms embed CSRF tokens in hidden inputs; admin panels validate the token on all POST/PUT/DELETE requests; SPAs pass the token in a custom header like X-CSRF-Token.
Common mistakes: Skipping CSRF protection on "API" routes assuming they won't be used by browsers — any API accepting cookie authentication is vulnerable to CSRF; use SameSite=Strict cookies as a defense-in-depth measure alongside CSRF tokens.
const { Pool } = require('pg');
const pool = new Pool();
// BAD — vulnerable to SQL injection
const bad = `SELECT * FROM users WHERE name = '${userInput}'`;
// GOOD — parameterized query
const result = await pool.query(
'SELECT * FROM users WHERE name = $1 AND age = $2',
[userName, userAge]
);
// GOOD — using Sequelize ORM
const user = await User.findOne({
where: { name: userName } // Automatically parameterized
});
// GOOD — Mongoose (NoSQL) with schema validation
const user = await User.findOne({ email: req.body.email });
Why it matters: SQL injection remains one of the most prevalent and dangerous vulnerabilities (OWASP A03) — a single injectable endpoint can expose or destroy an entire database, including user passwords, financial records, and PII.
Real applications: Login forms with parameterized queries are safe even if a user enters ' OR '1'='1; it is treated as a literal string value, not SQL syntax, so the query returns no rows.
Common mistakes: Using string interpolation for query fragments like ORDER BY clauses (which cannot be parameterized) without manual whitelisting — always validate ORDER BY field names against an explicit allowlist of known column names.
const helmet = require('helmet');
app.use(helmet());
// Helmet sets these headers by default:
// Content-Security-Policy — controls resource loading
// X-Content-Type-Options: nosniff — prevents MIME sniffing
// X-Frame-Options: SAMEORIGIN — prevents clickjacking
// X-XSS-Protection: 0 — disables buggy browser XSS filter
// Strict-Transport-Security — enforces HTTPS
// X-DNS-Prefetch-Control — controls DNS prefetching
// X-Permitted-Cross-Domain-Policies — restricts Adobe Flash/PDF
// Customize specific headers
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "trusted-cdn.com"]
}
},
frameguard: { action: 'deny' }
}));
Why it matters: Default Express apps send no security headers — without helmet, clickjacking, MIME sniffing, and missing HSTS leave the application unnecessarily exposed to well-known attack vectors that take less than a minute to fix.
Real applications: Every production Express API adds app.use(helmet()) as the first middleware; security scanners like Mozilla Observatory and Qualys SSL Labs check for these headers and grade the application accordingly.
Common mistakes: Using helmet's default CSP which blocks all inline scripts and external resources — many apps break immediately; start with Content-Security-Policy-Report-Only to observe violations before enforcing, then tune the directives.
express-rate-limit middleware makes it easy to apply limits globally or to specific routes, with stricter limits on authentication endpoints. Use a Redis-backed store in production to share limits across multiple server instances.
const rateLimit = require('express-rate-limit');
// General API rate limiter
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
message: { error: 'Too many requests, try again later' },
standardHeaders: true,
legacyHeaders: false
});
app.use('/api/', apiLimiter);
// Stricter limiter for auth routes
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
message: { error: 'Too many login attempts' }
});
app.use('/api/login', authLimiter);
Why it matters: Without rate limiting on login endpoints, a script can try millions of username/password combinations; even with bcrypt hashing, 5 requests per 15 minutes limits brute-force attempts to 480 per day.
Real applications: Auth endpoints (/login, /forgot-password, /register) use stricter limits (5 req/15min) than general API endpoints (100 req/15min); OTP verification endpoints may use 3 attempts per token.
Common mistakes: Using in-memory rate limiters on a multi-instance deployment — each instance has its own counter so the effective limit is max * instanceCount; always use Redis storage for consistent limits across load-balanced instances.
express-validator library provides both validation and sanitization in a single middleware chain, covering email normalization, HTML escaping, and type coercion. Always validate on the server side even if client-side validation exists — never trust user input regardless of origin.
const { body, validationResult } = require('express-validator');
app.post('/register',
body('email').isEmail().normalizeEmail(),
body('name').trim().escape().isLength({ min: 2, max: 50 }),
body('age').isInt({ min: 0, max: 150 }),
body('website').optional().isURL(),
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
// Input is now validated and sanitized
}
);
// For MongoDB — sanitize to prevent NoSQL injection
const mongoSanitize = require('express-mongo-sanitize');
app.use(mongoSanitize()); // Removes $ and . from req.body/query
Why it matters: Server-side validation is the last line of defense — client-side validation can be bypassed with DevTools or curl in under 10 seconds; all input must be treated as untrusted at the server boundary.
Real applications: Registration endpoints trim and escape name fields to prevent stored XSS; email fields are normalized to lowercase canonical form to prevent duplicate accounts with mixed case; MongoDB queries sanitize operators to prevent injection.
Common mistakes: Only validating input format (length, regex) without sanitizing HTML characters — a name field that passes length validation but contains <script> tags will cause XSS if ever rendered without escaping; always both validate AND sanitize.
https module or terminate TLS at a reverse proxy like Nginx; in production, terminating at the proxy provides better performance since TLS handling is offloaded from Node. Use Let's Encrypt for free, automatically renewable certificates and always redirect HTTP to HTTPS.
const https = require('https');
const fs = require('fs');
const express = require('express');
const app = express();
const options = {
key: fs.readFileSync('private-key.pem'),
cert: fs.readFileSync('certificate.pem'),
ca: fs.readFileSync('ca-cert.pem') // Optional CA chain
};
https.createServer(options, app).listen(443, () => {
console.log('HTTPS server running on port 443');
});
// Redirect HTTP to HTTPS
const http = require('http');
http.createServer((req, res) => {
res.writeHead(301, { Location: `https://${req.headers.host}${req.url}` });
res.end();
}).listen(80);
Why it matters: Without HTTPS, credentials, tokens, and session cookies are transmitted in plaintext and can be intercepted on public WiFi; modern browsers mark HTTP sites as "Not Secure" and refuse to send cookies with the Secure flag.
Real applications: Production deployments terminate TLS at an Nginx or load balancer layer, forwarding plain HTTP internally; HSTS headers instruct browsers to never connect over plain HTTP, even if a user types http:// manually.
Common mistakes: Forgetting to redirect HTTP traffic to HTTPS — if port 80 remains open without a redirect, mixed-content warnings appear and users inserting http:// manually bypass encryption; use a 301 redirect and include HSTS with a max-age of at least one year.
cors middleware, specifying allowed origins, methods, and headers rather than wildcards. Never use origin: '*' with credentials: true — browsers block this combination, and it would expose credentialed API access to any domain.
const cors = require('cors');
// Allow all origins (development only)
app.use(cors());
// Allow specific origins (production)
const corsOptions = {
origin: ['https://myapp.com', 'https://admin.myapp.com'],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true, // Allow cookies
maxAge: 86400 // Cache preflight for 24 hours
};
app.use(cors(corsOptions));
// Per-route CORS
app.get('/api/public', cors(), (req, res) => {
res.json({ data: 'accessible from anywhere' });
});
Why it matters: CORS misconfigurations are a leading cause of credential theft in SPAs — an overly permissive origin allowlist combined with credentials: true allows a malicious site to make authenticated requests to your API using a victim's cookies.
Real applications: Public APIs serving read-only data can use origin: '*' safely; APIs serving authenticated user data must maintain an explicit origin allowlist and use the credentials: true flag only for trusted first-party frontends.
Common mistakes: Dynamically reflecting the request's Origin header back in the response without validating it against a known allowlist — this effectively grants all origins full CORS access and defeats the purpose of the policy entirely.
dotenv package to load variables from .env files during development, and always add .env to .gitignore. Validate all required environment variables at startup using an explicit check to fail fast rather than discovering missing config at runtime.
// .env file (add to .gitignore!)
DATABASE_URL=mongodb://localhost:27017/mydb
JWT_SECRET=your-256-bit-secret
API_KEY=sk-abc123
// Load with dotenv
require('dotenv').config();
const dbUrl = process.env.DATABASE_URL;
const secret = process.env.JWT_SECRET;
// Validate required env vars at startup
const required = ['DATABASE_URL', 'JWT_SECRET', 'API_KEY'];
for (const key of required) {
if (!process.env[key]) {
console.error(`Missing required env var: ${key}`);
process.exit(1);
}
}
Why it matters: Hardcoded credentials in source code are routinely discovered by attackers scanning public GitHub repositories with tools that search for API key patterns; a single leaked secret can result in data breach, cloud infrastructure compromise, or fraudulent charges.
Real applications: CI/CD pipelines inject secrets via environment variables or secret management services; Docker deployments use environment flags or mounted secret files; AWS Lambda/ECS retrieve secrets from Parameter Store or Secrets Manager at runtime.
Common mistakes: Accidentally committing .env files to version control even briefly — Git history preserves the secret permanently; if this happens, immediately rotate all exposed credentials and consider the entire secret compromised regardless of how quickly it was reverted.
npm audit in CI/CD pipelines and fail builds on high/critical severity issues to catch vulnerabilities before deployment. Use automated tools like Dependabot, Snyk, or Renovate for continuous vulnerability monitoring and automated pull requests.
# Check for vulnerabilities
npm audit
# Fix automatically (compatible updates)
npm audit fix
# Check for outdated packages
npm outdated
# Update packages
npm update
# Update a specific package to latest
npm install lodash@latest
# Use Snyk for deeper analysis
npx snyk test
npx snyk monitor
Why it matters: The event-stream incident (malicious code injected into a popular npm package with millions of weekly downloads) demonstrated that supply chain attacks affect entire ecosystems; any compromised transitive dependency can execute arbitrary code in your app.
Real applications: GitHub Dependabot automatically opens PRs for dependency updates with security fixes; Snyk integrates into the CI pipeline to block deployments when new high/critical CVEs are detected in the dependency tree.
Common mistakes: Running npm audit fix --force blindly — it can upgrade packages across major version boundaries, introducing breaking changes; always review the audit report and test updates in staging before applying them to production.
$gt, $ne, and $regex in user input to bypass authentication checks or extract unauthorized data. Attackers send JSON objects instead of strings, causing queries to match records they should not see; for example, {"$ne": ""} as a password matches every document. Use express-mongo-sanitize and strict schema validation with explicit type casting to prevent these attacks.
// Vulnerable login — attacker sends { "$ne": "" } as password
app.post('/login', async (req, res) => {
// If req.body.password = { "$ne": "" }
// This matches ANY user with a non-empty password
const user = await User.findOne({
email: req.body.email,
password: req.body.password // DANGEROUS
});
});
// Fix 1: Sanitize input
const mongoSanitize = require('express-mongo-sanitize');
app.use(mongoSanitize()); // Strips $ and . from input
// Fix 2: Explicit type casting
app.post('/login', async (req, res) => {
const email = String(req.body.email);
const password = String(req.body.password);
const user = await User.findOne({ email });
if (!user || !(await bcrypt.compare(password, user.password))) {
return res.status(401).json({ error: 'Invalid credentials' });
}
});
// Fix 3: Schema-level validation
const userSchema = new mongoose.Schema({
email: { type: String, required: true },
password: { type: String, required: true }
});
Why it matters: Unlike SQL injection where the syntax is strings, NoSQL injection uses valid JSON — a login endpoint vulnerable to {"$ne": ""} may grant admin access to any attacker who sends the right query operator without knowing any valid credentials.
Real applications: Authentication endpoints using Mongoose add express-mongo-sanitize as global middleware to strip all MongoDB operators from req.body; Mongoose schemas with type: String reject objects, providing an additional layer of protection.
Common mistakes: Passing req.body directly to findOne() or find() as a query object — if an attacker sends {"$where": "this.password.length > 0"}, it executes as MongoDB JavaScript; always cast fields to their expected primitive types before querying.
helmet.contentSecurityPolicy() or set the header directly with carefully tuned directives.
const helmet = require('helmet');
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "cdn.example.com"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "images.example.com"],
connectSrc: ["'self'", "api.example.com"],
fontSrc: ["'self'", "fonts.googleapis.com"],
objectSrc: ["'none'"],
frameAncestors: ["'none'"],
upgradeInsecureRequests: []
}
}));
// Or set manually
app.use((req, res, next) => {
res.setHeader(
'Content-Security-Policy',
"default-src 'self'; script-src 'self' cdn.example.com"
);
next();
});
Why it matters: CSP provides defense-in-depth — even if an XSS vulnerability exists and an attacker successfully injects a script, CSP prevents that script from executing or exfiltrating data to attacker-controlled servers by blocking unauthorized sources.
Real applications: Banking applications use strict CSP with nonces for the few legitimate inline scripts; script-src 'self' ensures only scripts served from the same origin execute; Report-URI directives log CSP violations to a monitoring endpoint for detection.
Common mistakes: Adding 'unsafe-inline' to scriptSrc to make existing inline scripts work — this completely defeats CSP against XSS; instead use nonce-based or hash-based CSP where each legitimate inline script gets a unique token.
httpOnly flag prevents JavaScript from reading cookies (XSS protection), secure ensures they are only sent over HTTPS, and sameSite: 'strict' blocks them from cross-site requests (CSRF protection). Each attribute is required for a complete defense since omitting any one leaves a specific attack vector open.
app.use(session({
secret: process.env.SESSION_SECRET,
cookie: {
httpOnly: true, // Prevents JavaScript access (XSS protection)
secure: true, // Only sent over HTTPS
sameSite: 'strict', // Prevents CSRF attacks
maxAge: 3600000, // 1 hour expiry
domain: '.myapp.com',
path: '/'
}
}));
// For individual cookies
res.cookie('token', value, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 24 * 60 * 60 * 1000,
signed: true // Detect tampering with cookie-parser secret
});
// Use cookie-parser with a secret for signed cookies
const cookieParser = require('cookie-parser');
app.use(cookieParser(process.env.COOKIE_SECRET));
Why it matters: A single missing attribute can undermine session security — without httpOnly, XSS can steal session cookies; without secure, cookies are sent over HTTP and susceptible to network interception; without sameSite, CSRF can use the cookie in forged requests.
Real applications: Express session middleware in production always sets httpOnly: true, secure: true, sameSite: 'strict'; authentication cookies on banking apps use short maxAge values (15-30 minutes) with session renewal on activity to limit exposure windows.
Common mistakes: Setting secure: true in production but not in development, then forgetting to test whether the production configuration actually works — or setting sameSite: 'none' to support embedded cross-origin contexts without realizing this requires secure: true to function at all.
express.json() and express.urlencoded() middleware accept a limit option to cap the accepted body size. Set different limits based on the expected content type — most JSON APIs need only 50kb-100kb but file upload endpoints need higher limits.
const express = require('express');
const app = express();
// Global payload limits
app.use(express.json({ limit: '10kb' }));
app.use(express.urlencoded({ extended: true, limit: '10kb' }));
// Per-route limits for file uploads
app.post('/upload',
express.json({ limit: '50mb' }),
(req, res) => {
// Handle large upload
}
);
// Raw body limit for webhooks
app.post('/webhook',
express.raw({ type: 'application/json', limit: '1mb' }),
(req, res) => {
const payload = JSON.parse(req.body);
}
);
// Custom error handler for payload too large
app.use((err, req, res, next) => {
if (err.type === 'entity.too.large') {
return res.status(413).json({ error: 'Payload too large' });
}
next(err);
});
Why it matters: An attacker sending a 1GB JSON body to an Express endpoint without size limits can crash the server or cause a 10-30 second stall parsing the payload; without limits, a single malicious request can take down the entire API for all users.
Real applications: REST APIs set express.json({ limit: '50kb' }) for standard endpoints; avatar upload endpoints might allow 5MB; video upload endpoints proxy directly to object storage without buffering in Node at all, avoiding the memory issue entirely.
Common mistakes: Setting a generous default limit like '50mb' "to be safe" without realizing that Node.js buffers the entire body in memory before parsing — even a modest 100 concurrent requests at 50MB each would allocate 5GB of memory and likely crash the process.
hpp middleware selects the last value for each parameter, preventing array injection that could bypass typeof checks or whitelist validation. This is a subtle attack that is easy to overlook but straightforward to fix with a single middleware.
const hpp = require('hpp');
// Without HPP: ?sort=name&sort=DROP TABLE users
// req.query.sort = ['name', 'DROP TABLE users']
// With HPP: picks the last value
app.use(hpp());
// req.query.sort = 'DROP TABLE users'
// Whitelist parameters that should allow arrays
app.use(hpp({
whitelist: ['tags', 'categories', 'ids']
}));
// ?tags=js&tags=node → req.query.tags = ['js', 'node'] (allowed)
// ?sort=name&sort=malicious → req.query.sort = 'malicious' (last wins)
// Manual protection without middleware
app.get('/search', (req, res) => {
const sort = Array.isArray(req.query.sort)
? req.query.sort[req.query.sort.length - 1]
: req.query.sort;
});
Why it matters: If a middleware validates that typeof req.query.sort === 'string', an attacker sending ?sort=asc&sort[$regex]=.* makes req.query.sort an array, causing the check to pass an array to a function that expects a string, potentially enabling injection or crashing the handler.
Real applications: APIs that accept filter and sort parameters via query strings use hpp to normalize duplicates; allowlist specific parameters that legitimately accept multiple values (e.g., ?tag=js&tag=node) using hpp({ whitelist: ['tag'] }).
Common mistakes: Forgetting to whitelist parameters that intentionally accept arrays — without the whitelist option, hpp will collapse all multi-value parameters to a single value, silently breaking features that depend on sending multiple filter values.