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 sniffingX-Frame-Options: DENY— prevents clickjackingX-XSS-Protection: 1; mode=block— basic XSS filterStrict-Transport-Security— enforces HTTPSContent-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
/apiroutes - 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)