Express Security Basics: helmet, Rate Limiting, NoSQL Injection and Config Checks

MERN Stack Full Course: MongoDB, Express, React and Node.js · Authentication and Security · 6 min read · Updated

Layers of defence

  1. Security is several small layers, not one big switch.
  2. A rate limiter on the login routes stops someone trying thousands of passwords.
  3. NoSQL injection sends objects like {"$gt": ""} instead of strings. We reject keys that start with $.
  4. helmet adds security headers to every reply. TRUST_PROXY makes the limiter see the real visitor address.
  5. At start-up the app refuses to run in production with a weak or missing JWT secret.

A working login is not a secure login. Real APIs are scanned and attacked within hours of going online, usually by automated scripts, not by someone who knows your app. This lesson adds four protections, each small, that stop the most common automated attacks. None of them is a substitute for the ones you already have (hashing, token checks, ownership), and together they make a solid base.

1. Safe headers with helmet

// Lesson 23: security basics.
import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import mongoose from 'mongoose';
import { checkEnv } from './config/env.js';
import { connectDB } from './config/db.js';
import { logger } from './middleware/logger.js';
import { authLimiter, rejectOperators } from './middleware/security.js';
import { notFound, errorHandler } from './middleware/error.js';
import authRouter from './routes/auth.js';
import jobsRouter from './routes/jobs.js';

const app = express();
const port = Number(process.env.PORT) || 5000;

// Behind nginx or a host's load balancer, set TRUST_PROXY=1 so the rate limiter sees the real visitor IP.
if (process.env.TRUST_PROXY) app.set('trust proxy', Number(process.env.TRUST_PROXY) || process.env.TRUST_PROXY);

app.use(helmet());   // sets safe HTTP headers (and removes X-Powered-By)
app.use(logger);
app.use(cors({ origin: (process.env.CLIENT_ORIGIN || 'http://localhost:5173').split(',') })); // comma separated list
app.use(express.json({ limit: '100kb' }));
app.use(rejectOperators);

app.get('/api/health', (req, res) => res.json({ ok: true, db: mongoose.connection.readyState === 1 ? 'connected' : 'not connected' }));
app.use('/api/auth', authLimiter, authRouter);
app.use('/api/jobs', jobsRouter);

app.use(notFound);
app.use(errorHandler);

try {
  checkEnv();
  await connectDB();
  app.listen(port, () => console.log(`API listening on http://localhost:${port}`));
} catch (err) {
  console.error('Could not start:', err.message);
  process.exit(1);
}

helmet() is one line that sets a group of HTTP response headers that tell browsers to behave safely: do not guess content types (X-Content-Type-Options), do not allow your pages inside frames on other sites (clickjacking), only use HTTPS, and apply a Content Security Policy. It also removes the X-Powered-By: Express header that advertises your technology. It is cheap, it rarely causes problems, and it is the first thing a security review looks for.

2. Rate limiting

import rateLimit from 'express-rate-limit';

// Slow down password guessing: at most 20 sign-in or sign-up calls per IP address every 15 minutes.
export const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  limit: Number(process.env.AUTH_RATE_LIMIT) || 20,
  standardHeaders: 'draft-7', // sends RateLimit headers so a client can see how many calls are left
  legacyHeaders: false,
  message: { message: 'Too many attempts. Please wait a few minutes and try again.' },
});

// MongoDB treats an object like { "$gt": "" } as a query operator. If a client sends one where we expect
// text, an attacker can change the meaning of our query ("NoSQL injection"). We refuse any key that starts
// with "$" or contains ".", at any depth, in the JSON body.
const hasOperatorKeys = (value) =>
  value !== null && typeof value === 'object' &&
  Object.entries(value).some(([k, v]) => k.startsWith('$') || k.includes('.') || hasOperatorKeys(v));

export function rejectOperators(req, res, next) {
  if (hasOperatorKeys(req.body)) return res.status(400).json({ message: 'Field names cannot start with $ or contain a dot' });
  next();
}

Without a limit, a script can try thousands of passwords a minute against /api/auth/login. express-rate-limit counts requests per IP address in a time window and answers 429 Too Many Requests once a person goes over. We apply it to /api/auth only (20 attempts per 15 minutes), because that is where guessing happens. standardHeaders tells the client how many tries remain.

One production detail catches many people. Behind nginx or a host's load balancer, every request appears to come from the proxy's address, so *everyone shares one limit*. Setting TRUST_PROXY=1 tells Express to read the real visitor address from the X-Forwarded-For header. Only set it when you really are behind one proxy; otherwise a visitor could fake the header.

3. NoSQL injection

In SQL, the famous attack is typing ' OR 1=1 -- into a form. MongoDB has a cousin. Remember that filters use operator objects like { $gt: '' }. If your login does User.findOne({ email: req.body.email }) and a client sends the JSON { "email": { "$gt": "" } }, the filter becomes “email greater than empty text”, which matches the first user. The attacker never needed an email.

Two defences are used together. First, check types: lesson 21's login already refuses anything that is not a string. Second, rejectOperators in security.js walks through the JSON body and refuses any key that starts with $ or contains a dot, at any depth. Our app never needs such keys from clients, so refusing them is safe.

4. Fail at start when configuration is wrong

// Lesson 23: fail at startup, with a clear message, instead of failing later in a confusing way.
const required = ['MONGODB_URI', 'JWT_SECRET'];

export function checkEnv() {
  const missing = required.filter((k) => !process.env[k]);
  if (missing.length) throw new Error(`Missing environment variables: ${missing.join(', ')}. Copy .env.example to .env.`);
  const production = process.env.NODE_ENV === 'production';
  if (production && process.env.JWT_SECRET.length < 32) throw new Error('JWT_SECRET must be at least 32 characters in production');
  if (production && /change-me/i.test(process.env.JWT_SECRET)) throw new Error('JWT_SECRET is still the example value');
}

checkEnv stops the server immediately, with a clear message, if MONGODB_URI or JWT_SECRET is missing, and, in production, if the secret is short or still has the example value. A server that starts with a missing secret and then fails on the first login is much harder to debug than one that refuses to start.

A short security checklist

  • Hash passwords; use long random secrets from .env.
  • Check the token and the owner on every private route.
  • Validate and whitelist all input; clamp numbers; escape text used in regular expressions.
  • Limit request size (express.json({ limit })) and request rate.
  • Keep dependencies updated; run npm audit now and then and read what it says before changing anything.
  • Never log passwords, tokens or full request bodies.

Your task

  1. Run npm install in server (adds helmet and express-rate-limit). Restart and call any route with curl -i to see the new headers.
  2. Send eleven wrong logins in a row with AUTH_RATE_LIMIT=10 set in .env. Which request is the first to answer 429?
  3. Send { "email": { "$gt": "" }, "password": "x" } to login. What do you get?
  4. Remove JWT_SECRET from .env and start the server. Read the message.

How to check it worked

  • Responses carry X-Content-Type-Options and no X-Powered-By.
  • The 11th wrong login within the window answers 429.
  • The operator-object login answers 400.

Summary

  • helmet sets safe headers; rate limiting slows guessing; operator objects are refused.
  • Behind a proxy set TRUST_PROXY=1 so limits use real visitor addresses.
  • Fail fast at start when required settings are missing.

Download the solution (mern-lesson-23.zip)

Frequently asked questions

What is NoSQL injection?

Sending a MongoDB operator object, such as { "$gt": "" }, where the code expects text, so that the query matches more than intended. Defend by checking input types and refusing keys that start with $.

Does helmet make my app secure?

It makes browsers handle your responses more safely and removes some information leaks. It does not replace authentication, validation or authorization. Think of it as one inexpensive layer.

Why does the rate limit need TRUST_PROXY behind nginx?

Behind a proxy every request seems to come from the proxy's IP address, so all visitors would share one limit. TRUST_PROXY tells Express to use the forwarded visitor address instead.