Security Hardening Express.js Applications
A practical guide to securing Express.js applications covering Helmet.js, CORS, input validation, XSS prevention, CSRF protection, rate limiting, and OWASP Top 10 mitigations.
Security Hardening Express.js Applications
Overview
Express.js ships with virtually no security defaults. Out of the box, it does not set protective HTTP headers, does not validate input, and does not rate-limit requests — leaving your application exposed to the full spectrum of web attacks. This guide walks through every layer of defense you should apply to a production Express application, from HTTP headers and input sanitization to session management and dependency auditing. If you are deploying Express behind a reverse proxy and serving real users, every section here applies to you.
Prerequisites
- Node.js v16 or later installed
- Working knowledge of Express.js routing and middleware
- Familiarity with HTTP headers and cookies
- A terminal with npm available
- Basic understanding of common web vulnerabilities (XSS, CSRF, injection)
HTTP Security Headers with Helmet.js
The single highest-impact change you can make to an Express application is adding Helmet. Helmet is a collection of middleware functions that set HTTP response headers to protect against well-known web vulnerabilities. Without it, Express sends no security headers at all.
Install it first:
npm install helmet
Then apply it as early as possible in your middleware stack:
var express = require("express");
var helmet = require("helmet");
var app = express();
// Apply Helmet with all defaults
app.use(helmet());
With that single line, Helmet sets the following headers on every response:
Content-Security-Policy— restricts sources for scripts, styles, imagesCross-Origin-Embedder-Policy— controls embedding behaviorCross-Origin-Opener-Policy— isolates browsing contextCross-Origin-Resource-Policy— restricts cross-origin resource loadingX-DNS-Prefetch-Control— disables DNS prefetchingX-Frame-Options— prevents clickjacking via iframesStrict-Transport-Security— enforces HTTPS (HSTS)X-Download-Options— prevents IE from executing downloadsX-Content-Type-Options— prevents MIME type sniffingX-Permitted-Cross-Domain-Policies— restricts Adobe Flash/PDF cross-domain accessReferrer-Policy— controls referrer header leakageX-XSS-Protection— disables the broken browser XSS filter (setting it to0is actually safer than enabling it)
You should customize Helmet rather than relying entirely on defaults, especially for Content Security Policy. We will cover CSP in its own section below.
CORS Configuration Best Practices
Cross-Origin Resource Sharing (CORS) controls which domains can make requests to your API. The cors package is the standard approach in Express, but misconfiguring it is one of the most common security mistakes I see in production applications.
npm install cors
Never use a wildcard origin in production:
// DANGEROUS — allows any origin
app.use(cors());
// CORRECT — whitelist specific origins
var cors = require("cors");
var allowedOrigins = [
"https://yourdomain.com",
"https://admin.yourdomain.com"
];
var corsOptions = {
origin: function(origin, callback) {
// Allow requests with no origin (mobile apps, curl, server-to-server)
if (!origin) {
return callback(null, true);
}
if (allowedOrigins.indexOf(origin) !== -1) {
return callback(null, true);
}
return 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));
Key points: always explicitly list allowed origins, restrict methods to only those your API uses, and set credentials: true only if you actually need cookies in cross-origin requests. The maxAge option reduces preflight request overhead.
Input Validation and Sanitization
Never trust any data that arrives from a client. Use express-validator to validate and sanitize every input field before your route handler processes it.
npm install express-validator
var { body, param, validationResult } = require("express-validator");
app.post("/api/users",
body("email").isEmail().normalizeEmail(),
body("name").trim().isLength({ min: 1, max: 100 }).escape(),
body("age").optional().isInt({ min: 0, max: 150 }),
body("role").isIn(["user", "editor"]),
function(req, res) {
var errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
// Safe to use req.body here
processUser(req.body);
}
);
The .escape() method converts characters like <, >, &, ', and " into their HTML entity equivalents, which prevents stored XSS. The .normalizeEmail() method lowercases and cleans up email addresses. The .isIn() method whitelists acceptable values rather than blacklisting bad ones — always prefer whitelisting.
For URL parameters, validate those too:
app.get("/api/users/:id",
param("id").isMongoId(),
function(req, res) {
var errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
// Safe to use req.params.id
}
);
SQL Injection and NoSQL Injection Prevention
SQL injection is the most well-known injection attack, but NoSQL injection against MongoDB is equally dangerous and more commonly overlooked in Node.js applications.
SQL injection prevention with parameterized queries:
var pg = require("pg");
var pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
// VULNERABLE — string concatenation
app.get("/api/users", function(req, res) {
var query = "SELECT * FROM users WHERE name = '" + req.query.name + "'";
// An attacker sends: ?name=' OR '1'='1
pool.query(query); // Executes: SELECT * FROM users WHERE name = '' OR '1'='1'
});
// SAFE — parameterized query
app.get("/api/users", function(req, res) {
pool.query("SELECT * FROM users WHERE name = $1", [req.query.name], function(err, result) {
if (err) {
return res.status(500).json({ error: "Database error" });
}
res.json(result.rows);
});
});
NoSQL injection prevention with MongoDB:
var sanitize = require("mongo-sanitize");
// VULNERABLE — attacker sends { "username": { "$gt": "" }, "password": { "$gt": "" } }
app.post("/api/login", function(req, res) {
db.collection("users").findOne({
username: req.body.username,
password: req.body.password
});
});
// SAFE — sanitize strips $ operators from input
app.post("/api/login", function(req, res) {
var username = sanitize(req.body.username);
var password = sanitize(req.body.password);
db.collection("users").findOne({
username: username,
password: password // Also hash passwords — see session section
});
});
Install mongo-sanitize with npm install mongo-sanitize. It strips any keys that start with $ from objects, which prevents operators like $gt, $ne, and $regex from being injected into queries.
XSS Prevention Strategies
Cross-site scripting (XSS) remains in the OWASP Top 10 because it is so easy to introduce and so hard to eliminate completely. Defense requires multiple layers.
Layer 1: Output encoding. If you render user-supplied data in HTML, encode it. Template engines like Pug auto-escape by default — use #{variable} not !{variable}. In Handlebars, use {{variable}} not {{{variable}}}.
Layer 2: Sanitize HTML input. If you must accept rich text, use sanitize-html:
npm install sanitize-html
var sanitizeHtml = require("sanitize-html");
var clean = sanitizeHtml(req.body.comment, {
allowedTags: ["b", "i", "em", "strong", "a", "p", "br"],
allowedAttributes: {
"a": ["href"]
},
allowedSchemes: ["https"]
});
Layer 3: Content Security Policy. CSP is the most powerful defense against XSS because it tells the browser which script sources are legitimate, even if an attacker manages to inject a <script> tag. We cover CSP in detail below.
Layer 4: HttpOnly cookies. Set httpOnly: true on all cookies so that JavaScript cannot read them. This limits the damage even if XSS succeeds.
CSRF Protection
Cross-Site Request Forgery tricks a user's browser into making authenticated requests to your application without their knowledge. The classic defense is a synchronizer token pattern.
Note: the original csurf package is deprecated. Use csrf-csrf (or a similar actively maintained library) instead:
npm install csrf-csrf cookie-parser
var { doubleCsrf } = require("csrf-csrf");
var cookieParser = require("cookie-parser");
app.use(cookieParser(process.env.COOKIE_SECRET));
var { doubleCsrfProtection, generateToken } = doubleCsrf({
getSecret: function() { return process.env.COOKIE_SECRET; },
cookieName: "__Host-psifi.x-csrf-token",
cookieOptions: {
httpOnly: true,
sameSite: "strict",
secure: true
},
size: 64,
getTokenFromRequest: function(req) {
return req.headers["x-csrf-token"];
}
});
// Apply CSRF protection to state-changing routes
app.use("/api", doubleCsrfProtection);
// Provide a token endpoint for SPA clients
app.get("/api/csrf-token", function(req, res) {
var token = generateToken(req, res);
res.json({ token: token });
});
// Clients must send the token in the x-csrf-token header
For server-rendered forms (Pug templates), inject the token into a hidden field and include it in form submissions. For SPAs, fetch the token from the endpoint and include it in the header of every POST, PUT, and DELETE request.
Rate Limiting and Brute Force Protection
Without rate limiting, an attacker can hammer your login endpoint with millions of password guesses or overwhelm your API with requests. Use express-rate-limit for general throttling and add stricter limits to sensitive endpoints.
npm install express-rate-limit
var rateLimit = require("express-rate-limit");
// General API rate limit
var apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window per IP
standardHeaders: true, // Return rate limit info in RateLimit-* headers
legacyHeaders: false,
message: { error: "Too many requests, please try again later." }
});
app.use("/api/", apiLimiter);
// Strict rate limit for authentication
var authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5, // 5 login attempts per 15 minutes
skipSuccessfulRequests: true,
message: { error: "Too many login attempts. Try again in 15 minutes." }
});
app.use("/api/login", authLimiter);
app.use("/api/register", authLimiter);
In production behind a reverse proxy (nginx, DigitalOcean App Platform, AWS ALB), you need to trust the proxy so Express reads the real client IP from X-Forwarded-For:
app.set("trust proxy", 1); // Trust first proxy
Without this setting, every client appears to come from the same IP (the proxy's IP), and rate limiting becomes useless. However, do not set trust proxy to true — that trusts all proxies in the chain, which can be spoofed.
Secure Session Management
Sessions are one of the most sensitive components of your application. A stolen session ID gives an attacker full access to the user's account.
npm install express-session connect-mongo
var session = require("express-session");
var MongoStore = require("connect-mongo");
app.use(session({
name: "__Host-sessionId", // Rename from default "connect.sid"
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false, // Do not create sessions for anonymous users
store: MongoStore.create({
mongoUrl: process.env.DB_MONGO,
ttl: 24 * 60 * 60, // 24 hour TTL
crypto: {
secret: process.env.SESSION_ENCRYPTION_KEY
}
}),
cookie: {
secure: true, // HTTPS only
httpOnly: true, // No JavaScript access
sameSite: "strict", // No cross-site sending
maxAge: 24 * 60 * 60 * 1000, // 24 hours
domain: "yourdomain.com",
path: "/"
}
}));
Critical points for session security:
- Rename the session cookie. The default
connect.sidname tells attackers you are using Express. Change it. - Set
saveUninitialized: false. This prevents creating empty sessions, which reduces storage usage and prevents session fixation on anonymous users. - Use a real session store. Never use the default
MemoryStorein production — it leaks memory and does not scale across processes. - Rotate session IDs after login. Call
req.session.regenerate()after successful authentication to prevent session fixation attacks.
app.post("/login", function(req, res) {
authenticateUser(req.body, function(err, user) {
if (err || !user) {
return res.status(401).json({ error: "Invalid credentials" });
}
req.session.regenerate(function(err) {
if (err) {
return res.status(500).json({ error: "Session error" });
}
req.session.userId = user.id;
req.session.role = user.role;
res.json({ success: true });
});
});
});
HTTPS Enforcement and HSTS
Every production Express application must enforce HTTPS. If you are behind a reverse proxy that terminates TLS (which is the standard setup on platforms like DigitalOcean App Platform, Heroku, or AWS), redirect HTTP to HTTPS at the application level as a safety net:
// Redirect HTTP to HTTPS when behind a TLS-terminating proxy
app.use(function(req, res, next) {
if (req.headers["x-forwarded-proto"] !== "https") {
return res.redirect(301, "https://" + req.hostname + req.url);
}
next();
});
Helmet's default configuration enables HSTS (HTTP Strict Transport Security), which tells browsers to only access your site over HTTPS for a specified duration. Customize it for maximum protection:
app.use(helmet({
hsts: {
maxAge: 31536000, // 1 year in seconds
includeSubDomains: true,
preload: true // Submit to HSTS preload list
}
}));
The preload directive lets you submit your domain to the browser HSTS preload list, which hardcodes HTTPS enforcement directly into Chrome, Firefox, and other browsers. Once preloaded, even the very first request to your domain will use HTTPS.
Content Security Policy (CSP)
CSP is arguably the single most important security header. A well-configured CSP can completely neutralize XSS attacks by restricting where the browser can load scripts, styles, and other resources from.
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "https://cdn.jsdelivr.net"],
styleSrc: ["'self'", "https://cdn.jsdelivr.net", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'", "https://api.yourdomain.com"],
fontSrc: ["'self'", "https://fonts.gstatic.com"],
objectSrc: ["'none'"],
mediaSrc: ["'none'"],
frameSrc: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"],
frameAncestors: ["'none'"],
upgradeInsecureRequests: []
}
}
}));
Start strict and loosen as needed. The most common mistake is adding 'unsafe-inline' and 'unsafe-eval' to scriptSrc because something broke. Instead, use nonces for inline scripts:
var crypto = require("crypto");
app.use(function(req, res, next) {
res.locals.cspNonce = crypto.randomBytes(16).toString("base64");
next();
});
app.use(helmet({
contentSecurityPolicy: {
directives: {
scriptSrc: ["'self'", function(req, res) {
return "'nonce-" + res.locals.cspNonce + "'";
}]
}
}
}));
In your Pug template:
script(nonce=cspNonce)
console.log("This inline script is allowed by CSP");
Dependency Vulnerability Scanning
Your dependencies are attack surface. A single vulnerable transitive dependency can compromise your entire application. Run npm audit regularly and integrate it into your CI pipeline.
# Check for known vulnerabilities
npm audit
# Fix automatically where possible
npm audit fix
# View detailed report
npm audit --json
# Fail CI build on high/critical vulnerabilities
npm audit --audit-level=high
Example output:
found 3 vulnerabilities (1 moderate, 2 high)
run `npm audit fix` to fix 2 of them.
1 vulnerability requires manual review. See the full report for details.
Go beyond npm audit. Use tools like Snyk or Socket for deeper analysis:
npx snyk test
Pin your dependency versions in package-lock.json and review it when it changes. A supply chain attack can slip a malicious version into a patch release, and if you are using semver ranges like ^1.0.0, you will pull it automatically.
Secure Cookie Configuration
Every cookie your application sets should be locked down:
res.cookie("preferences", value, {
secure: true, // Only sent over HTTPS
httpOnly: true, // Not accessible via JavaScript
sameSite: "strict", // Not sent with cross-site requests
maxAge: 86400000, // 24 hours — do not use session cookies for persistent data
domain: "yourdomain.com",
path: "/",
signed: true // Tamper detection via cookie-parser secret
});
The __Host- cookie prefix (supported in modern browsers) enforces that a cookie must be set with secure, must not have a domain attribute, and must have path set to /. This prevents subdomain attacks:
res.cookie("__Host-session", value, {
secure: true,
httpOnly: true,
sameSite: "strict",
path: "/"
// No domain attribute — that is the point of __Host-
});
Request Size Limits
Without size limits, an attacker can send a massive JSON payload that consumes all your server memory and crashes the process.
// Limit JSON bodies to 10kb
app.use(express.json({ limit: "10kb" }));
// Limit URL-encoded bodies to 10kb
app.use(express.urlencoded({ extended: false, limit: "10kb" }));
// For file upload endpoints, set a larger limit only on that route
var multer = require("multer");
var upload = multer({
limits: {
fileSize: 5 * 1024 * 1024, // 5MB max
files: 1 // 1 file max
}
});
app.post("/upload", upload.single("avatar"), function(req, res) {
// Handle upload
});
Setting extended: false on urlencoded uses the simpler querystring library instead of qs, which reduces the attack surface for prototype pollution vulnerabilities that have historically affected qs.
Parameter Pollution Prevention
HTTP Parameter Pollution (HPP) occurs when an attacker sends duplicate query parameters to bypass validation or confuse your application logic. For example, ?sort=name&sort=DROP TABLE users might cause unexpected behavior.
npm install hpp
var hpp = require("hpp");
// Apply HPP protection — takes the last value for duplicate parameters
app.use(hpp());
// Whitelist parameters that legitimately accept arrays
app.use(hpp({
whitelist: ["tags", "categories"]
}));
Without hpp, Express puts duplicate parameters into an array. If your code expects a string and receives an array, it can crash or behave unpredictably. With hpp, duplicate parameters are resolved to the last value, and whitelisted parameters retain array behavior.
Security Logging and Audit Trails
You cannot defend what you cannot see. Security logging gives you visibility into authentication events, authorization failures, and suspicious activity.
npm install winston
var winston = require("winston");
var securityLogger = winston.createLogger({
level: "info",
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
defaultMeta: { service: "security" },
transports: [
new winston.transports.File({ filename: "logs/security.log" }),
new winston.transports.File({ filename: "logs/error.log", level: "error" })
]
});
// Log authentication events
function logAuthEvent(event, req, success, details) {
securityLogger.info({
event: event,
success: success,
ip: req.ip,
userAgent: req.headers["user-agent"],
userId: req.session ? req.session.userId : null,
path: req.originalUrl,
method: req.method,
details: details
});
}
// Middleware to log all 401 and 403 responses
app.use(function(req, res, next) {
var originalSend = res.send;
res.send = function(body) {
if (res.statusCode === 401 || res.statusCode === 403) {
securityLogger.warn({
event: "access_denied",
statusCode: res.statusCode,
ip: req.ip,
path: req.originalUrl,
method: req.method,
userAgent: req.headers["user-agent"]
});
}
return originalSend.call(this, body);
};
next();
});
// Usage in login route
app.post("/login", function(req, res) {
authenticateUser(req.body, function(err, user) {
if (!user) {
logAuthEvent("login_failed", req, false, { email: req.body.email });
return res.status(401).json({ error: "Invalid credentials" });
}
logAuthEvent("login_success", req, true, { userId: user.id });
// ... proceed with session creation
});
});
Security log entries look like this:
{
"event": "login_failed",
"success": false,
"ip": "203.0.113.42",
"userAgent": "Mozilla/5.0 ...",
"userId": null,
"path": "/login",
"method": "POST",
"details": { "email": "[email protected]" },
"service": "security",
"timestamp": "2026-02-13T14:22:31.000Z",
"level": "info"
}
Monitor these logs for patterns: repeated failed logins from the same IP, unusual user agents, or requests to admin endpoints from unexpected IPs.
Common OWASP Top 10 Mitigations
Here is a summary of how the techniques in this article map to the OWASP Top 10 (2021 edition):
| OWASP Category | Express Mitigation |
|---|---|
| A01: Broken Access Control | Role-based middleware, CORS whitelist, frameSrc: 'none' |
| A02: Cryptographic Failures | HTTPS enforcement, HSTS, secure cookies, session encryption |
| A03: Injection | Parameterized queries, mongo-sanitize, express-validator |
| A04: Insecure Design | Input validation, rate limiting, request size limits |
| A05: Security Misconfiguration | Helmet defaults, remove X-Powered-By, custom error handlers |
| A06: Vulnerable Components | npm audit, snyk test, lock file review |
| A07: Auth Failures | Session regeneration, brute force protection, secure cookies |
| A08: Data Integrity Failures | CSRF tokens, CSP, dependency integrity checks |
| A09: Logging Failures | Winston security logger, audit trail middleware |
| A10: SSRF | Validate and whitelist outbound URLs, block internal IPs |
Remove the X-Powered-By header. Helmet does this by default, but you can also do it explicitly:
app.disable("x-powered-by");
Always use custom error handlers that do not leak stack traces in production:
app.use(function(err, req, res, next) {
securityLogger.error({
event: "unhandled_error",
error: err.message,
stack: err.stack,
path: req.originalUrl,
ip: req.ip
});
if (process.env.NODE_ENV === "production") {
res.status(500).json({ error: "Internal server error" });
} else {
res.status(500).json({ error: err.message, stack: err.stack });
}
});
Complete Working Example
Here is a fully hardened Express.js application that combines every technique from this article into a single, runnable server:
var express = require("express");
var helmet = require("helmet");
var cors = require("cors");
var rateLimit = require("express-rate-limit");
var hpp = require("hpp");
var cookieParser = require("cookie-parser");
var session = require("express-session");
var { body, validationResult } = require("express-validator");
var { doubleCsrf } = require("csrf-csrf");
var sanitizeHtml = require("sanitize-html");
var winston = require("winston");
var crypto = require("crypto");
var app = express();
// ----------------------------
// 1. Trust proxy (required behind reverse proxy)
// ----------------------------
app.set("trust proxy", 1);
// ----------------------------
// 2. Security headers via Helmet
// ----------------------------
app.use(function(req, res, next) {
res.locals.cspNonce = crypto.randomBytes(16).toString("base64");
next();
});
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", function(req, res) {
return "'nonce-" + res.locals.cspNonce + "'";
}],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
frameSrc: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"],
frameAncestors: ["'none'"],
upgradeInsecureRequests: []
}
},
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true
}
}));
// ----------------------------
// 3. HTTPS redirect
// ----------------------------
app.use(function(req, res, next) {
if (req.headers["x-forwarded-proto"] !== "https") {
return res.redirect(301, "https://" + req.hostname + req.url);
}
next();
});
// ----------------------------
// 4. CORS
// ----------------------------
var allowedOrigins = ["https://yourdomain.com"];
app.use(cors({
origin: function(origin, callback) {
if (!origin || allowedOrigins.indexOf(origin) !== -1) {
return callback(null, true);
}
return callback(new Error("Not allowed by CORS"));
},
credentials: true,
methods: ["GET", "POST", "PUT", "DELETE"]
}));
// ----------------------------
// 5. Request parsing with size limits
// ----------------------------
app.use(express.json({ limit: "10kb" }));
app.use(express.urlencoded({ extended: false, limit: "10kb" }));
app.use(cookieParser(process.env.COOKIE_SECRET));
// ----------------------------
// 6. HPP protection
// ----------------------------
app.use(hpp());
// ----------------------------
// 7. Rate limiting
// ----------------------------
var generalLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
standardHeaders: true,
legacyHeaders: false
});
var authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
skipSuccessfulRequests: true
});
app.use("/api/", generalLimiter);
app.use("/api/login", authLimiter);
// ----------------------------
// 8. Sessions
// ----------------------------
app.use(session({
name: "__Host-sid",
secret: process.env.SESSION_SECRET || "dev-secret-change-me",
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === "production",
httpOnly: true,
sameSite: "strict",
maxAge: 24 * 60 * 60 * 1000,
path: "/"
}
}));
// ----------------------------
// 9. CSRF protection
// ----------------------------
var { doubleCsrfProtection, generateToken } = doubleCsrf({
getSecret: function() { return process.env.COOKIE_SECRET || "dev-csrf-secret"; },
cookieName: "__Host-csrf",
cookieOptions: { httpOnly: true, sameSite: "strict", secure: process.env.NODE_ENV === "production" },
getTokenFromRequest: function(req) { return req.headers["x-csrf-token"]; }
});
// ----------------------------
// 10. Security logger
// ----------------------------
var securityLogger = winston.createLogger({
level: "info",
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
transports: [
new winston.transports.Console(),
new winston.transports.File({ filename: "logs/security.log" })
]
});
// ----------------------------
// Routes
// ----------------------------
app.get("/api/csrf-token", function(req, res) {
var token = generateToken(req, res);
res.json({ token: token });
});
app.post("/api/contact",
doubleCsrfProtection,
body("name").trim().isLength({ min: 1, max: 100 }).escape(),
body("email").isEmail().normalizeEmail(),
body("message").trim().isLength({ min: 1, max: 5000 }),
function(req, res) {
var errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
var cleanMessage = sanitizeHtml(req.body.message, {
allowedTags: [],
allowedAttributes: {}
});
securityLogger.info({
event: "contact_form",
ip: req.ip,
email: req.body.email
});
res.json({ success: true, message: "Message received" });
}
);
// Global error handler — no stack traces in production
app.use(function(err, req, res, next) {
securityLogger.error({
event: "unhandled_error",
error: err.message,
path: req.originalUrl,
ip: req.ip
});
var statusCode = err.status || 500;
if (process.env.NODE_ENV === "production") {
res.status(statusCode).json({ error: "An error occurred" });
} else {
res.status(statusCode).json({ error: err.message });
}
});
var PORT = process.env.PORT || 3000;
app.listen(PORT, function() {
console.log("Hardened server running on port " + PORT);
});
Before hardening — default Express response headers:
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
After hardening — response headers with Helmet and security middleware:
HTTP/1.1 200 OK
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-abc123...'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self'; font-src 'self'; object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Resource-Policy: same-origin
Referrer-Policy: no-referrer
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
X-Content-Type-Options: nosniff
X-DNS-Prefetch-Control: off
X-Download-Options: noopen
X-Frame-Options: SAMEORIGIN
X-Permitted-Cross-Domain-Policies: none
X-XSS-Protection: 0
RateLimit-Limit: 100
RateLimit-Remaining: 99
RateLimit-Reset: 1707836551
Content-Type: application/json; charset=utf-8
Set-Cookie: __Host-sid=s%3A...; Path=/; HttpOnly; Secure; SameSite=Strict
The difference is dramatic. Every response now carries protective headers that instruct the browser to enforce security policies on behalf of your application.
Common Issues and Troubleshooting
1. CORS preflight requests failing with 403
Access to XMLHttpRequest at 'https://api.example.com/data' from origin
'https://example.com' has been blocked by CORS policy: Response to preflight
request doesn't pass access control check.
This usually means your CORS configuration does not handle OPTIONS requests correctly, or the origin is not in your whitelist. Verify the exact origin string (including protocol and port). Check that credentials: true is set if you are sending cookies. Make sure the preflight OPTIONS route is not being blocked by your authentication middleware.
2. CSP blocking inline scripts
Refused to execute inline script because it violates the following Content
Security Policy directive: "script-src 'self'". Either the 'unsafe-inline'
keyword, a hash, or a nonce is required to enable inline execution.
Do not add 'unsafe-inline' to scriptSrc — that defeats the purpose of CSP. Instead, use nonces as shown in the CSP section. Generate a unique nonce per request, pass it to your template, and add it to each inline <script> tag with the nonce attribute.
3. Rate limiter blocking all requests behind a proxy
HTTP 429 Too Many Requests
{ "error": "Too many requests, please try again later." }
If every user is hitting the rate limit immediately, you likely forgot app.set("trust proxy", 1). Without it, Express sees every request as coming from your proxy's IP address, so a single shared counter rapidly exhausts the limit. Set the trust proxy value to match your deployment topology — 1 for a single proxy (most common), or the specific number of proxies in your chain.
4. Session cookies not being set in the browser
Set-Cookie header is present in the response but the cookie does not appear
in document.cookie or DevTools Application > Cookies.
This happens when secure: true is set but you are testing over HTTP (localhost without TLS). Either set secure conditionally based on NODE_ENV, or use a local TLS proxy. Also check that sameSite: "strict" is not blocking the cookie on cross-origin redirects during OAuth flows — in that case, use sameSite: "lax" instead.
5. express-validator not catching malicious input
// This passes validation even though it contains script tags
body("name").isLength({ min: 1, max: 100 })
Validation and sanitization are separate concerns. .isLength() checks length but does not strip HTML. You must chain .escape() or .trim() explicitly. Always combine validation (reject bad input) with sanitization (clean accepted input):
body("name").trim().isLength({ min: 1, max: 100 }).escape()
Best Practices
Apply Helmet before all other middleware. Security headers should be set on every response, including error responses. If Helmet is registered after your routes, a route that errors before reaching Helmet will send unprotected headers.
Use environment-specific security settings. Do not disable
securecookies or HTTPS redirect in production just because they are inconvenient in development. Useprocess.env.NODE_ENVto conditionally apply strict settings only in production.Rotate secrets regularly. Session secrets, CSRF secrets, and cookie signing secrets should be rotated on a schedule. Use an array for
session.secretto support graceful rotation — Express will sign new cookies with the first element and accept cookies signed with any element.Never store passwords in plain text. Use
bcryptwith a cost factor of at least 12. Do not use MD5, SHA-1, or SHA-256 for password hashing — they are not designed for this purpose and can be brute-forced at billions of hashes per second on modern GPUs.Implement defense in depth. No single security measure is sufficient. XSS prevention requires output encoding, input sanitization, CSP headers, and HttpOnly cookies all working together. Assume each layer will eventually be bypassed and stack your defenses accordingly.
Keep dependencies minimal and updated. Every dependency is an attack surface. Remove unused packages, run
npm auditin CI, and reviewpackage-lock.jsondiffs. Consider usingnpm ciin production builds to install exact versions from the lock file.Log security events to a dedicated transport. Separate security logs from application logs so they can be monitored, alerting on patterns like repeated authentication failures, requests to admin endpoints, or unusual error spikes.
Test your security headers. Use securityheaders.com and the Mozilla Observatory to scan your production site. These tools grade your headers and identify missing protections that are easy to overlook.
Disable directory listing and hide technology fingerprints. Do not serve your
node_modulesfolder,.envfiles, or.gitdirectory. Useexpress.staticwith explicit paths and disabledotfilesaccess:express.static("public", { dotfiles: "deny" }).