Lesson 10 of 20

Middleware in Express

Built-in and Custom Middleware

Everything in an Express application is middleware. The body parser is middleware, your route handlers are middleware, the error handler is middleware with one extra argument. Once that clicks, Express stops being a collection of unrelated features and becomes a single simple idea: a list of functions, walked in order, once per request.

Three built-in ones do most of the everyday work. express.json() reads the request body and parses it, but only when the client said it was sending JSON. express.urlencoded({ extended: true }) does the same for the format an ordinary HTML form posts. express.static(dir) serves files straight from a folder, so images and stylesheets never touch a route handler at all.

Your own middleware is an ordinary function with the signature (req, res, next). It can inspect the request, attach something useful to req, decide the request should stop here, or simply call next() and let the chain continue. The logger in the example is worth reading closely: it cannot log the status code straight away, because at that moment nothing has been sent yet, so it subscribes to the 'finish' event on the response and logs when the reply has actually gone out.

Apply middleware as narrowly as the job allows. app.use(fn) runs it for every request, app.use('/api', fn) only for paths under that prefix, and passing the function into a single route restricts it there. A request logger genuinely belongs at the top for everything; an authentication check belongs on the routes that need it, because a check applied too broadly eventually blocks your own health endpoint.

One cost to keep in mind: middleware runs on every matching request. Compiling a regular expression, reading a configuration file or building a large lookup table inside a middleware means paying for it thousands of times instead of once. Do that work when the module loads, and keep the per-request path as thin as you can — this is the same single-thread argument from lesson 1, arriving in a place where it is easy to forget.

Example
const path = require('node:path');
const express = require('express');
const app = express();

// --- Built-in middleware ---
app.use(express.json({ limit: '100kb' }));        // JSON bodies, with a size cap
app.use(express.urlencoded({ extended: true })); // HTML form posts

// Anchor the folder to THIS file — 'public' alone resolves against cwd
app.use(express.static(path.join(__dirname, 'public')));

// --- Custom Middleware: Request Logger ---
function logger(req, res, next) {
  const start = Date.now();
  res.on('finish', () => {
    const duration = Date.now() - start;
    console.log(`${req.method} ${req.url} ${res.statusCode} - ${duration}ms`);
  });
  next();
}
app.use(logger);

// --- Custom Middleware: Auth Check ---
function requireAuth(req, res, next) {
  const token = req.headers.authorization;
  if (!token) {
    return res.status(401).json({ error: 'No token provided' });
  }
  // Verify token here...
  next();
}

// Apply to specific routes only
app.get('/api/profile', requireAuth, (req, res) => {
  res.json({ user: 'Alice' });
});
  • express.json() — parse JSON bodies; set a limit rather than accepting anything
  • express.urlencoded({ extended: true }) — parse ordinary HTML form posts
  • express.static(dir) — serve files; anchor dir with path.join(__dirname, ...)
  • app.use(fn) · app.use('/api', fn) · app.get(path, fn, handler)
  • Expensive setup belongs at module load, not inside a function that runs per request
  • res.on('finish', ...) — the only place you can log the final status code
Notes
  • express.static deserves a moment of care. It serves every file in the folder you point it at, so pointing it at your project root instead of a dedicated public folder publishes your source code — and possibly your .env — to anyone who guesses a filename. Point it at a folder containing nothing you would mind a stranger downloading.

Error Handling Middleware

An error handler is a middleware with four parameters, and Express identifies it by counting them. (err, req, res, next) is an error handler; (req, res, next) is not. That is unusual enough to be worth memorising, because writing only three parameters makes Express treat the function as an ordinary middleware and pass the request object in as err, producing behaviour that is genuinely baffling to debug.

It runs in two situations: when something calls next(err) with an argument, and when a synchronous handler throws. Passing anything to next is the signal "abandon the normal chain and go straight to the error handler", which is why every route and middleware in between is silently skipped. That skipping is the feature — you do not want the rest of a request to keep running after it has already failed.

Register it last, after every route and after your 404 handler. An error handler placed above the routes never sees their errors, because by the time an error happens the chain has already moved past it. This is the same ordering rule as everything else in this lesson, but with a symptom that misleads: errors appear to vanish, and Express prints its own default response instead of yours.

Async handlers need care. Depending on which major version of Express you are using, a rejected promise inside an async route handler may not reach this function at all — the request hangs while Node reports an unhandled rejection somewhere you are not watching. Until you have checked the version the project uses, wrap async handlers in try/catch and call next(err). Lesson 17 shows how to do that once instead of in every route.

What you send matters as much as catching it. Use the status code the error carries if it has one, a plain 500 if it does not, and never send the stack trace to the client. A stack trace tells a stranger your folder layout, your library versions and frequently your database structure. Log the detail on the server where it is useful; send the user a sentence and, ideally, an id they can quote to you.

Example
const express = require('express');
const app = express();

app.use(express.json());

// Route that might throw an error
app.get('/api/users/:id', (req, res, next) => {
  try {
    const id = parseInt(req.params.id);
    if (isNaN(id)) {
      const error = new Error('Invalid user ID');
      error.statusCode = 400;
      throw error;
    }
    res.json({ id, name: 'Alice' });
  } catch (err) {
    next(err); // Pass error to error handler
  }
});

// 404 handler — must be after all routes
app.use((req, res, next) => {
  res.status(404).json({ error: 'Route not found' });
});

// Error handling middleware — Express recognises it by the FOUR arguments.
// Do not remove `next` even though it is unused; the count is the signal.
app.use((err, req, res, next) => {
  console.error(err.stack);              // full detail stays on the server

  const status = err.statusCode || 500;
  res.status(status).json({
    // Only messages you set deliberately are safe to show a client
    error: status < 500 ? err.message : 'Internal Server Error'
  });
});

app.listen(process.env.PORT || 3000);
  • Four parameters — (err, req, res, next) — or it is not an error handler
  • next(err) skips straight past every remaining normal middleware
  • Register it last: after the routes, after the 404 handler
  • Send the stack trace to your logs, never to the client
  • 4xx for errors you raised deliberately; a generic 500 for everything else
  • Async rejections may not arrive here on their own — see lesson 17
Notes
  • A four-argument function whose next is never called looks like dead code, and sooner or later somebody "tidies up" that unused parameter. The moment they do, it silently becomes an ordinary middleware, every error stops being handled, and requests begin failing in ways that have nothing to do with the commit that caused it. Leave a comment on that line explaining why the parameter must stay.

Third-Party Middleware Worth Knowing

Most of what a real Express application needs already exists as middleware, and installing four well-known packages is a better use of an evening than writing four half-finished versions of them yourself. The ones worth meeting early are morgan for request logging, helmet for security headers, cors for cross-origin access, and a rate limiter.

morgan prints one line per request: method, path, status code, duration. Use the 'dev' format while you are working, because it is short and colour-coded, and a machine-readable format in production, because production logs get searched by tools rather than by eyes. One rule regardless of format — never log request bodies. Passwords, tokens and card details end up sitting in plain text in a file that gets copied around.

helmet sets a batch of response headers that instruct the browser to behave more strictly: do not guess content types, do not allow this page inside a frame, and so on. It is one line and costs nothing at runtime. Be aware that some of its defaults, particularly around content security policy, are strict enough to block scripts, fonts or images you load from another domain — so if a page suddenly stops rendering after you add it, that is where to look rather than in your own markup.

A rate limiter caps how many requests one client may make in a window. Without one, a login route can be attacked with a password list at whatever speed your server can answer, and a single misbehaving script can consume all the capacity everyone else is sharing. Apply a general limit across /api and a much tighter one on login and password-reset routes, where the cost of a wrong guess should be measured in seconds.

Two pieces of judgement. Everything you register runs on every request it matches, so mount narrowly and read what a package actually does before trusting it with all your traffic. And if your app sits behind a proxy or load balancer, configure Express's trust proxy setting — otherwise every client appears to arrive from the proxy's single address, which quietly makes rate limiting useless and your logs meaningless.

Example
// npm install morgan cors helmet

const express = require('express');
const morgan = require('morgan');   // HTTP request logger
const cors = require('cors');       // Cross-Origin Resource Sharing
const helmet = require('helmet');   // Security headers

const app = express();

// Security headers first — they should apply to everything below
app.use(helmet());

// Configure CORS ONCE. app.use(cors()) with no options allows every origin:
// acceptable for a public read-only API, wrong for anything with credentials.
app.use(cors({
  origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  credentials: true
}));

app.use(morgan('dev'));         // GET /api/users 200 5ms
app.use(express.json({ limit: '100kb' }));

// Behind a proxy, this is what makes req.ip the real client address
// app.set('trust proxy', 1);

app.get('/api/data', (req, res) => {
  res.json({ message: 'Secured and logged!' });
});

app.listen(3000);
  • morgan — request logging; 'dev' while working, a parsable format in production
  • helmet — a batch of stricter response headers in one line
  • cors — decide which origins a browser may let read your responses
  • express-rate-limit — cap requests per client; tighter limits on login routes
  • Never log request bodies — passwords and tokens end up in plain text
  • app.set('trust proxy', ...) behind a proxy, or every client looks like one address
Notes
  • Rate limiting is the item on this list that stops being optional the moment your API is public. A login route without it can be attacked with a password list at whatever speed your server can reply, and unlike most security problems, this one is fixed by a few lines of configuration rather than a redesign.

CORS Is a Browser Rule, Not Server Security

CORS produces more confusion per line of code than anything else in web development, and nearly all of it comes from one misunderstanding. CORS is enforced by the browser, not by your server. Your API received the request, ran the handler and sent a perfectly good response; the browser then refused to hand that response to the JavaScript that asked for it.

Browsers do this because of the same-origin policy. A page on one origin — scheme, host and port taken together — must not be able to read data from a different origin on the user's behalf, because the user's cookies would travel with the request and any website could quietly read your bank balance. CORS is the mechanism by which a server says "this specific origin is allowed to read my responses", and it says it in response headers.

That is why the fix is always on the server. A frontend running on http://localhost:5173 calling an API on http://localhost:3000 is cross-origin — a different port is enough — and the API has to send an Access-Control-Allow-Origin header naming that frontend. Nothing you change inside the fetch call will produce it, which is why so much time gets lost editing the wrong half of the application.

Preflight is the part that catches people out. For anything beyond a simple request — a custom header such as Authorization, a PUT or DELETE, a JSON content type — the browser first sends an OPTIONS request asking permission, and only sends the real request if the answer permits it. So a route that works from curl can fail in a browser purely because your app never answers OPTIONS. The cors middleware handles that for you, provided it is registered before your routes.

The security point is the one to carry away. CORS protects people using browsers. It does nothing at all to stop curl, Postman, a Python script or any server anywhere from calling your API and reading every byte of the reply. If an endpoint must be restricted, that is authentication and authorisation — lesson 16 — not a CORS setting. Every year somebody ships an admin API they believe is private because a browser once blocked them.

Example
const cors = require('cors');

// Development: allow the frontend dev server you actually use
app.use(cors({ origin: 'http://localhost:5173' }));

// Production: read it from the environment, and be specific
app.use(cors({
  origin: process.env.CORS_ORIGIN,   // e.g. https://myapp.example
  credentials: true                  // only if you send cookies
}));

// This combination is INVALID and browsers reject it outright:
// app.use(cors({ origin: '*', credentials: true }));

// Register CORS BEFORE your routes, or the preflight OPTIONS request
// falls through to your 404 handler and the browser blocks the real one.
app.use('/api', apiRoutes);

// What the browser is actually reading, on the response:
//   Access-Control-Allow-Origin:  https://myapp.example
//   Access-Control-Allow-Headers: Content-Type, Authorization
//   Access-Control-Allow-Methods: GET, POST, PUT, DELETE

// And what CORS does NOT stop — this works regardless of any origin setting:
//   curl https://myapp.example/api/admin/users
  • CORS is enforced by browsers; curl and Postman ignore it entirely
  • A different port, scheme or host all count as a different origin
  • The fix is server-side response headers — nothing in your fetch call changes it
  • Preflight OPTIONS requests must be answered, so register cors above your routes
  • origin: '*' together with credentials: true is rejected by browsers
  • CORS is not access control — restricting an endpoint means authentication
Notes
  • "Blocked by CORS policy" in the browser console is the browser complaining about your server's headers. Read the server's response, not your frontend code: curl -i against the same URL takes ten seconds, and if Access-Control-Allow-Origin is missing from what comes back, you have already found the problem.

The Order That Works

Because middleware runs in registration order, the shape of app.js is a design decision rather than a matter of taste. Most Express applications converge on the same stack, and adopting it saves you from discovering each ordering rule by way of its bug.

Security headers first, since they should apply to everything. CORS next, so preflight requests are answered before any route has a chance to 404 them. Then logging, then the body parsers, then static files, then your routes, then the 404 catch-all, and finally the error handler. Each position has a reason, and the two most easily got wrong are in the middle.

The parsers sit above the routes because a route cannot read a body that nobody parsed. They sit below the logger because you want a log line even for a request whose body was rejected for being too large — put the logger underneath and those requests disappear from your logs precisely when you most want to see them.

For the middleware you write yourself, two rules cover nearly everything. Respond or call next(), exactly once, on every code path, with return in front of both so it is visible. And never keep per-request state in module scope: a let currentUser at the top of a middleware file is shared by every request the server is handling at that moment, so under real traffic one user will be shown another user's data. Attach it to req, which is created fresh for each request and thrown away afterwards.

Finally, remember the single thread. Middleware runs for every request, so a readFileSync, a large synchronous JSON.parse or a slow loop inside one is paid on every request and blocks all the others while it runs. Read the config file once at startup, where blocking costs nothing at all, and keep the per-request path asynchronous.

Example
// app.js — an ordering that holds up as the project grows
const path = require('node:path');
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const morgan = require('morgan');

const app = express();

// 1. Security headers — cheap, and should cover everything below
app.use(helmet());

// 2. CORS — must answer preflight OPTIONS before any route sees it
app.use(cors({ origin: process.env.CORS_ORIGIN }));

// 3. Logging — above the parsers, so oversized bodies still get logged
app.use(morgan('dev'));

// 4. Body parsers — a route cannot read a body nobody parsed
app.use(express.json({ limit: '100kb' }));
app.use(express.urlencoded({ extended: true }));

// 5. Static files — keep this folder well away from your API paths
app.use(express.static(path.join(__dirname, '..', 'public')));

// 6. Your routes
app.use('/api/books', bookRoutes);
app.use('/api/auth', authRoutes);

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

// 8. Something failed — four arguments, and always last
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(err.statusCode || 500).json({ error: 'Internal Server Error' });
});

module.exports = app;

// NEVER do this — module scope is shared by every request in flight
// let currentUser = null;
// app.use((req, res, next) => { currentUser = req.user; next(); });
  • helmet → cors → logger → body parsers → static → routes → 404 → error handler
  • Parsers above routes: a route cannot read a body nobody parsed
  • The 404 catch-all and the error handler are always the final two, in that order
  • Respond or call next() exactly once per path, with return in front of both
  • Never keep per-request state in module scope — it is shared across all requests
  • No blocking work inside a middleware; it is paid per request and stalls everyone else
Notes
  • The module-scope trap deserves dwelling on because it does not fail in testing. With one person clicking around, a shared let currentUser behaves flawlessly. With twenty simultaneous users it begins handing one person another person's data, intermittently, in a way that is close to impossible to reproduce on demand. Anything that varies per request goes on req, always.
Ask AI