Nodejs

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, images
  • Cross-Origin-Embedder-Policy — controls embedding behavior
  • Cross-Origin-Opener-Policy — isolates browsing context
  • Cross-Origin-Resource-Policy — restricts cross-origin resource loading
  • X-DNS-Prefetch-Control — disables DNS prefetching
  • X-Frame-Options — prevents clickjacking via iframes
  • Strict-Transport-Security — enforces HTTPS (HSTS)
  • X-Download-Options — prevents IE from executing downloads
  • X-Content-Type-Options — prevents MIME type sniffing
  • X-Permitted-Cross-Domain-Policies — restricts Adobe Flash/PDF cross-domain access
  • Referrer-Policy — controls referrer header leakage
  • X-XSS-Protection — disables the broken browser XSS filter (setting it to 0 is 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.sid name 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 MemoryStore in 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 secure cookies or HTTPS redirect in production just because they are inconvenient in development. Use process.env.NODE_ENV to 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.secret to 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 bcrypt with 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 audit in CI, and review package-lock.json diffs. Consider using npm ci in 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_modules folder, .env files, or .git directory. Use express.static with explicit paths and disable dotfiles access: express.static("public", { dotfiles: "deny" }).

References

Powered by Contentful