How do I secure an Express API?

Direct answer

Express ships with almost no defaults, so everything is opt-in. The essentials are helmet for headers, an explicit CORS origin rather than a wildcard, rate limiting on auth routes, a body size limit, and an error handler that doesn't return stack traces. Then the part no middleware covers: an ownership check on every route that reads a record.

Muhammad HasanUpdated

Express gives you a router and nothing else, which is the appeal and the problem. Every protection is something you added on purpose.

The middleware baseline

const helmet = require('helmet');
const rateLimit = require('express-rate-limit');

app.use(helmet());

app.use(cors({
  origin: 'https://yourapp.com',    // never true, never '*'
  credentials: true
}));

app.use(express.json({ limit: '100kb' }));

app.use('/auth', rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 20
}));

Four things worth understanding rather than copying.

helmet sets a set of security headers. Content Security Policy is the one worth configuring properly rather than leaving at defaults.

CORS with a wildcard. This turned up again and again across those 561 findings, and the cause was the same each time, it was set so a preview would render, and nobody went back to it. Name your origin.

Body size limits. Without one, express.json() accepts whatever it's sent. The default is not unlimited, but it's larger than most APIs need.

Rate limiting on auth routes. Login, password reset and OTP endpoints without throttling were another recurring finding. Stricter limits there than on the rest of the API.

Middleware order matters

Express runs middleware in registration order, and mistakes here are silent.

app.use('/api', requireAuth);
app.get('/api/orders/:id', handler);      // protected

app.get('/api/health', handler);          // deliberately public

// Registered before requireAuth — unprotected by accident
app.get('/api/admin/users', adminHandler);

Anything registered before the auth middleware is public. This is easy to get wrong during a refactor and produces no error.

The error handler

The default handler returns stack traces in development and it's common for that to survive into production.

app.use((err, req, res, next) => {
  console.error(err);
  res.status(err.status || 500).json({ error: 'Internal error' });
});

Log the detail server-side, return nothing useful to the caller. Also set NODE_ENV=production. Several defaults depend on it.

What middleware can't do

Everything above is global. The most consequential bug in an Express API isn't.

app.get('/api/orders/:id', requireAuth, async (req, res) => {
  const order = await db.orders.findById(req.params.id);
  res.json(order);
});

requireAuth confirms someone is logged in. Nothing confirms they own this order. That's broken object-level authorisation, and it was the most consequential thing we found across the AI-generated apps we scanned.

There's no middleware for it, because the check depends on what the route is doing. It has to be in the query:

const order = await db.orders.findOne({
  id: req.params.id,
  userId: req.user.id
});
if (!order) return res.status(404).end();

Every route that reads a record needs its own version of this, and getting it right on forty routes and missing the forty-first is the same outcome as never doing it. That's a coverage problem rather than a knowledge problem.

Go deeper

Kolega for AppSec teams

Related answers

See what your own repository returns

Connect a repo and run a scan. No credit card, no pipeline changes.

Get started for free