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 auditnow and then and read what it says before changing anything. - Never log passwords, tokens or full request bodies.
Your task
- Run
npm installinserver(addshelmetandexpress-rate-limit). Restart and call any route withcurl -ito see the new headers. - Send eleven wrong logins in a row with
AUTH_RATE_LIMIT=10set in.env. Which request is the first to answer 429? - Send
{ "email": { "$gt": "" }, "password": "x" }to login. What do you get? - Remove
JWT_SECRETfrom.envand start the server. Read the message.
How to check it worked
- Responses carry
X-Content-Type-Optionsand noX-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=1so limits use real visitor addresses. - Fail fast at start when required settings are missing.