Secure Your Express API: CORS, Helmet and Rate Limiting

Node.js and Express: Build a Back End API from Scratch · Data, Login and Going Live · 6 min read · Updated

Layers of protection

  1. Requests arrive from anywhere on the internet, some friendly and some not.
  2. Helmet adds safe headers and CORS only lets your own front end call the API from a browser.
  3. The rate limiter slows down clients that send too many requests, which blocks password guessing.
  4. Login checks and validation make sure only the right people send only valid data to your routes.

An API on the internet will be probed by strangers and by automated scripts within hours. You do not need to be a security expert to block the most common problems. A few well-known libraries and some careful habits remove a great deal of risk. This lesson adds them to the task API, and ends with a checklist to use before every launch. Security is never finished, and no list can promise that an app is safe. Treat this as a solid beginning.

CORS: allow your front end, and only it

Browsers have a rule: a web page may only call APIs on its own origin (the same protocol, domain and port) unless the API says otherwise. If your React app runs at http://localhost:5173 and your API at http://localhost:3100, the browser blocks the calls. The API opts in with CORS headers, and the cors package sets them. Allow the exact origin of your front end, and not every origin.

npm install cors helmet express-rate-limit
app.use(cors({ origin: allowedOrigin || false }));   // false: no cross-origin access
  • CORS is enforced by browsers. It does not protect your API from curl or scripts. Authentication and validation do that.
  • Do not use origin: '*' together with logins, unless you understand the effect.
  • When you test with curl and a different Origin header, the server still answers. A string origin makes the package always send your configured origin, and it is the browser that refuses to give the response to a page from another origin.
  • Read the allowed origin from .env, so development and production can differ.

Helmet: safer headers

helmet is a middleware that sets a group of HTTP headers that tell browsers to be careful. For example, X-Content-Type-Options: nosniff stops browsers guessing types, Strict-Transport-Security asks them to use HTTPS, and Content-Security-Policy limits where scripts may load from. It is one line, and it is a good default for any Express app. Express also announces itself with an X-Powered-By: Express header, which only helps attackers, so switch it off with app.disable('x-powered-by').

Rate limiting

Without a limit, a script can try thousands of passwords a minute against your login, or overload your server. express-rate-limit counts requests per client address and answers 429 Too Many Requests after a limit. Use a strict limit on login and registration, and a more relaxed one for the rest.

const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,   // 15 minutes
  limit: 20,                  // 20 requests per window per client
  standardHeaders: 'draft-7',
  legacyHeaders: false,
});
app.use('/api/auth', authLimiter, authRouter({ secret, users }));

We tested it: the first 20 login attempts get 401 (wrong password), and the 21st and 22nd get 429 with a Retry-After header. The store behind this limiter is the memory of one process. If you run several copies of your server, or restart often, use a shared store such as Redis. Also, behind a proxy like nginx, every request seems to come from the proxy's address unless you tell Express to trust it with app.set('trust proxy', 1). Do that only when a proxy really sits in front of Node, because otherwise clients can fake their address.

Limit what you accept

  • express.json({ limit: '10kb' }): a task needs only a few hundred bytes. A small limit prevents huge bodies from using up memory. Bigger bodies get 413, which our error handler explains.
  • Validate every field, as in lesson 10.
  • Cap list sizes and page through them.
  • Do not let users choose file paths or run commands with their input.

The full app so far

Here is the whole app.js with everything combined. It exports a function that builds the app, which is useful for testing in the next lesson, and it is the only place that knows the order of middleware.

import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';
import { createStore } from './store.js';
import { authRouter } from './routes/auth.js';
import { tasksRouter } from './routes/tasks.js';
import { requireAuth } from './middleware/auth.js';

export function createApp({ secret, dataFile, allowedOrigin } = {}) {
  if (!secret) throw new Error('A JWT secret is required');
  const app = express();
  const store = createStore(dataFile);
  const users = new Map();

  app.disable('x-powered-by');
  app.use(helmet());
  app.use(cors({ origin: allowedOrigin || false }));
  app.use(express.json({ limit: '10kb' }));

  app.use((req, res, next) => {
    const start = Date.now();
    res.on('finish', () => {
      if (process.env.NODE_ENV !== 'test') console.log(`${req.method} ${req.originalUrl} ${res.statusCode} ${Date.now() - start}ms`);
    });
    next();
  });

  app.get('/api/health', (req, res) => res.json({ ok: true }));

  const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 20, standardHeaders: 'draft-7', legacyHeaders: false });
  app.use('/api/auth', authLimiter, authRouter({ secret, users }));
  app.use('/api/tasks', requireAuth(secret), tasksRouter(store));

  app.use((req, res) => res.status(404).json({ error: 'Not found' }));

  // Error handler: four arguments. Express 5 sends rejected promises here too.
  app.use((err, req, res, next) => {
    if (err.type === 'entity.parse.failed') return res.status(400).json({ error: 'Body is not valid JSON' });
    if (err.type === 'entity.too.large') return res.status(413).json({ error: 'Body is too large' });
    console.error(err);
    res.status(500).json({ error: 'Something went wrong' });
  });

  return app;
}

Read the order. Security headers and CORS come first, then the body parser, then the logger. The login routes are limited. The task routes need a login. The 404 handler and the error handler are last. We checked the headers with curl and saw Content-Security-Policy, Strict-Transport-Security and X-Content-Type-Options, and no X-Powered-By.

Habits that protect you

  • Keep secrets out of git and out of logs. Do not log passwords, tokens or full request bodies.
  • Keep dependencies updated. Run npm audit regularly and read what it says before you run automatic fixes.
  • Use HTTPS everywhere in production.
  • Give each secret one job, and replace it if you think it leaked.
  • Check ownership of every record, not just login.
  • Return the same error for a wrong email and a wrong password.
  • Run the app as a normal user, not as root, on your server.
  • Back up your data and test restoring.

Your task

  1. Install cors, helmet and express-rate-limit and add them to app.js in the order shown.
  2. Run curl -I on /api/health and check for the security headers. Check that X-Powered-By is gone.
  3. Send 22 wrong logins in a loop and watch the status change to 429.
  4. Send a JSON body of more than 10 kilobytes and see the 413 response.

How to check it worked

  • The health check response has X-Content-Type-Options and no X-Powered-By.
  • The 21st login attempt within 15 minutes returns 429.
  • A request with an oversized body returns 413 with a JSON message.

Frequently asked questions

Does CORS protect my API from attackers?

No. CORS is enforced by browsers to protect users of web pages. Scripts and tools such as curl ignore it, so you still need authentication, validation and rate limits.

What is a good rate limit?

It depends on your app. Use a strict limit for login and registration, such as a few dozen attempts per 15 minutes per client, and a more relaxed one for ordinary routes. Watch real traffic and adjust.