Skip to main content

Security & Rate Limiting

Why API Security Matters

An Express server running without security hardening is exposed to a range of attacks: brute force, injection, information disclosure, and abuse. The good news is that most protections are easy to add with well-maintained packages.

This page covers the essential security layers for a production Express API.

Helmet — HTTP Security Headers

Helmet sets HTTP response headers that protect against common web vulnerabilities like XSS, clickjacking, and MIME-type sniffing. It's one line to add significant protection:

npm install helmet
import helmet from "helmet";

app.use(helmet());

Helmet sets headers like:

  • X-Content-Type-Options: nosniff — prevents MIME sniffing
  • X-Frame-Options: DENY — prevents clickjacking
  • X-XSS-Protection: 1; mode=block — basic XSS filter
  • Strict-Transport-Security — enforces HTTPS
  • Content-Security-Policy — controls resource loading

Add Helmet before any routes — first middleware in the chain.

Rate Limiting

Without rate limiting, anyone can hammer your API with thousands of requests per second — either for brute-force attacks (hammering your /login endpoint) or just overwhelming your server.

npm install express-rate-limit

Global Rate Limit

import rateLimit from "express-rate-limit";

const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // max 100 requests per window per IP
message: {
error: "Too many requests, please try again after 15 minutes",
},
standardHeaders: true, // return rate limit info in headers
legacyHeaders: false,
});

app.use("/api", globalLimiter);

Strict Limit for Auth Endpoints

Authentication endpoints deserve a much tighter limit to prevent brute-force attacks:

const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // max 10 login attempts
message: { error: "Too many login attempts, please try again later" },
skipSuccessfulRequests: true, // don't count successful logins
});

app.post("/api/auth/login", authLimiter, loginHandler);
app.post("/api/auth/register", authLimiter, registerHandler);
app.post("/api/auth/forgot-password", authLimiter, forgotPasswordHandler);

Input Sanitization

Never trust user input. Even with validation, always sanitize data that ends up in responses or databases.

npm install express-mongo-sanitize

Prevent NoSQL Injection

MongoDB query operators ($where, $gt, etc.) in request bodies can manipulate queries. express-mongo-sanitize strips them:

import mongoSanitize from "express-mongo-sanitize";
app.use(mongoSanitize());

Without this, an attacker could send:

{ "email": { "$gt": "" }, "password": { "$gt": "" } }

...and potentially bypass authentication.

Prevent XSS

The best XSS defense is a properly configured Content Security Policy (set by Helmet) combined with output encoding — never render raw user input as HTML. For cases where you must store and re-render HTML (e.g. rich text fields), sanitize with the maintained xss library at the point of output:

npm install xss
import xss from "xss";

// Sanitize at the point of rendering, not globally on all input
const safeHtml = xss(userProvidedHtmlString);

Limit Request Body Size

By default, Express accepts request bodies of any size. Limit this to prevent denial-of-service via large payloads:

app.use(express.json({ limit: "10kb" }));
app.use(express.urlencoded({ extended: false, limit: "10kb" }));

HTTPS Redirect

In production, redirect HTTP to HTTPS. If you're behind a proxy (Nginx, Heroku, etc.):

if (process.env.NODE_ENV === "production") {
app.use((req, res, next) => {
if (req.header("x-forwarded-proto") !== "https") {
return res.redirect(`https://${req.header("host")}${req.url}`);
}
next();
});
}

Security-Aware Error Handling

Don't leak stack traces or internal details to clients:

function errorHandler(err, req, res, next) {
console.error(err); // log the full error internally

// Only in development — send full error details
if (process.env.NODE_ENV === "development") {
return res.status(err.statusCode || 500).json({
error: err.message,
stack: err.stack,
});
}

// In production — generic message
res.status(err.statusCode || 500).json({
error: err.statusCode ? err.message : "Something went wrong",
});
}

This hides database connection strings, file paths, and other implementation details from attackers.

Prevent Parameter Pollution

An attacker can send ?sort=name&sort=password — most parsers return an array for duplicate params, which can crash code expecting a string:

npm install hpp
import hpp from "hpp";
app.use(hpp({
whitelist: ["filter", "fields"], // allow arrays for these params
}));

Putting It All Together

A secure Express app setup:

import express from "express";
import helmet from "helmet";
import cors from "cors";
import rateLimit from "express-rate-limit";
import mongoSanitize from "express-mongo-sanitize";
import hpp from "hpp";

const app = express();

// Security headers + CSP (primary XSS defense)
app.use(helmet());

// CORS
app.use(cors({ origin: process.env.ALLOWED_ORIGINS?.split(",") }));

// Body parsing with size limits
app.use(express.json({ limit: "10kb" }));
app.use(express.urlencoded({ extended: false, limit: "10kb" }));

// NoSQL injection prevention
app.use(mongoSanitize());
app.use(hpp());

// Global rate limit
app.use("/api", rateLimit({ windowMs: 15 * 60 * 1000, max: 100 }));

// Tighter limit for auth
const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 10 });
app.use("/api/auth", authLimiter);

// Routes
app.use("/api/users", usersRouter);
app.use("/api/auth", authRouter);

// Error handler (last)
app.use(errorHandler);

Security Checklist

Before deploying to production:

  • Helmet headers configured
  • Rate limiting on all /api routes
  • Stricter rate limiting on auth endpoints
  • NoSQL injection prevention (mongo-sanitize)
  • XSS sanitization
  • Request body size limited
  • Error messages don't leak stack traces
  • Environment variables for all secrets (never hardcoded)
  • HTTPS only (redirect HTTP)
  • JWT secrets are long, random, and in .env
  • Passwords hashed with bcrypt (never stored as plain text)
  • Input validation on all endpoints (see Validation)