Skip to main content

πŸ› οΈ Custom Middleware Development

Built-in and third-party middleware get you far, but real applications always need logic that's uniquely theirs β€” a specific auth scheme, a house style for error responses, a bespoke rate limit. This lesson teaches the patterns for writing your own middleware that's configurable, reusable, and testable.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain why and when to write custom middleware rather than reach for a package
  • Use the configurable factory pattern β€” a function that returns a middleware
  • Build practical middleware: request logging, authentication, authorization, validation, and rate limiting
  • Write structured error-handling middleware with a custom error class hierarchy
  • Compose middleware with factories, pipelines, and conditional application, and test it

Estimated Time: 45–55 minutes  β€’  Difficulty: Intermediate–Advanced

Hands-on: Write a configurable request-timing middleware and prove it with a test.

In This Lesson

Why Write Custom Middleware?

Reach for custom middleware when your need is specific to your application and no existing package fits cleanly. Common motivations:

  • Application-specific logic β€” business rules unique to your domain.
  • Cross-cutting concerns β€” things that touch many routes: request IDs, timing, audit trails.
  • A lightweight alternative β€” a small purpose-built function instead of a heavy dependency.
  • Integration β€” glue between Express and your own services.
  • Bespoke security β€” an auth or authorization model that's yours.
πŸ’‘ Furniture analogy: Built-in middleware is the furniture that comes with the house; third-party middleware is what you buy from a store; custom middleware is built-to-measure β€” designed to fit your space exactly.
flowchart TD A[Need some functionality] --> B{Does a solid package exist?} B -->|Yes, and it fits| C[Use built-in / third-party] B -->|No, or it's a poor fit| D[Write custom middleware] D --> E[App-specific logic] D --> F[Cross-cutting concern] D --> G[Bespoke security]

The Configurable Factory Pattern

The simplest custom middleware is a bare (req, res, next) function. But the moment you want it to behave differently in different places, you switch to the factory pattern: an outer function takes options and returns the middleware. This is exactly how morgan('dev') and cors({...}) work.

// Plain middleware
function simple(req, res, next) {
  next();
}
app.use(simple);

// Factory: takes options, returns middleware
function configurable(options = {}) {
  const opts = { enabled: true, logLevel: 'info', ...options }; // merge defaults

  return (req, res, next) => {
    if (!opts.enabled) return next();
    if (opts.logLevel === 'debug') console.log('debug:', req.path);
    next();
  };
}

app.use(configurable({ logLevel: 'debug' }));
The middleware factory pattern Options merge with defaults to produce merged options, which the returned middleware uses to decide whether to apply its logic before calling next. options object { logLevel: 'debug' } default options { enabled, logLevel } merged options closure captures these (req,res,next) the returned middleware
Figure 1 β€” The factory merges caller options with defaults, then returns a middleware whose closure remembers the merged configuration.

πŸ’‘ Why the closure matters

The returned function "remembers" opts through a JavaScript closure. That's how one factory can produce many independently-configured middleware instances β€” a lenient one for /public and a strict one for /admin β€” from the same code.

Request Logging Middleware

A classic first custom middleware: log each request with its status code and duration. Note how it hooks the response's finish event to measure the full round-trip:

function requestLogger(options = {}) {
  const {
    includeBody = false,
    excludePaths = ['/health', '/favicon.ico']
  } = options;

  return (req, res, next) => {
    if (excludePaths.includes(req.path)) return next();

    const start = Date.now();

    res.on('finish', () => {
      const entry = {
        timestamp: new Date().toISOString(),
        method: req.method,
        path: req.path,
        status: res.statusCode,
        duration: `${Date.now() - start}ms`
      };
      if (includeBody && req.body) entry.body = req.body;

      if (res.statusCode >= 500) console.error(entry);
      else if (res.statusCode >= 400) console.warn(entry);
      else console.log(entry);
    });

    next();
  };
}

app.use(requestLogger({ includeBody: true, excludePaths: ['/health', '/metrics'] }));
πŸ’‘ Analogy: This logger is a building's security-camera system: it records who arrived (the request), what happened (processing), and when they left (the response). The options decide which cameras run and how much detail they keep.

Authentication & Authorization

Two distinct jobs: authentication answers "who are you?"; authorization answers "are you allowed to do this?". Keep them in separate middleware so you can mix and match.

JWT authentication

This factory reads a Bearer token, verifies it, and attaches the decoded user to req.user. It uses the real jsonwebtoken library and modern async/await:

const jwt = require('jsonwebtoken');

function authenticate(options = {}) {
  const {
    secret = process.env.JWT_SECRET,
    credentialsRequired = true
  } = options;

  return (req, res, next) => {
    const header = req.get('authorization');

    if (!header) {
      if (credentialsRequired) {
        return res.status(401).json({ error: 'Authentication required' });
      }
      return next(); // optional auth: continue as a guest
    }

    if (!header.startsWith('Bearer ')) {
      return res.status(401).json({ error: 'Bearer token required' });
    }

    const token = header.slice(7);
    try {
      req.user = jwt.verify(token, secret); // throws on invalid/expired
      next();
    } catch (err) {
      if (err.name === 'TokenExpiredError') {
        return res.status(401).json({ error: 'Token has expired' });
      }
      return res.status(401).json({ error: 'Invalid token' });
    }
  };
}

// Required auth on a protected route
app.get('/api/profile', authenticate(), (req, res) => {
  res.json({ user: req.user });
});

// Optional auth β€” personalise if logged in, still works if not
app.get('/api/feed', authenticate({ credentialsRequired: false }), (req, res) => {
  res.json({ personalised: Boolean(req.user) });
});

Role-based authorization

Once req.user exists, a second middleware can gate on roles. Note it runs after authenticate():

function authorize(...requiredRoles) {
  return (req, res, next) => {
    if (!req.user) {
      return res.status(401).json({ error: 'Authentication required' });
    }
    const userRoles = req.user.roles || [];
    const allowed = requiredRoles.length === 0 ||
      requiredRoles.some(role => userRoles.includes(role));

    if (!allowed) {
      return res.status(403).json({ error: 'Insufficient permissions' });
    }
    next();
  };
}

app.get('/api/admin', authenticate(), authorize('admin'), (req, res) => {
  res.json({ message: 'Admin dashboard' });
});

app.get('/api/reports', authenticate(), authorize('admin', 'manager'), (req, res) => {
  res.json({ message: 'Reports' });
});

πŸ“– 401 vs 403

401 Unauthorized means "I don't know who you are" β€” authentication failed or is missing. 403 Forbidden means "I know who you are, but you may not do this" β€” authorization failed. Using the right code helps clients react correctly.

Structured Error Handling

A house-style error handler pairs beautifully with a small hierarchy of custom error classes. Route code throws a meaningful error; the handler turns it into a consistent JSON response and decides how much detail to reveal.

class AppError extends Error {
  constructor(message, statusCode = 500, code = 'INTERNAL_ERROR') {
    super(message);
    this.statusCode = statusCode;
    this.code = code;
    this.isOperational = true; // expected, not a bug
    Error.captureStackTrace(this, this.constructor);
  }
}

class ValidationError extends AppError {
  constructor(message, details = []) {
    super(message, 400, 'VALIDATION_ERROR');
    this.details = details;
  }
}

class NotFoundError extends AppError {
  constructor(resource = 'Resource') {
    super(`${resource} not found`, 404, 'NOT_FOUND');
  }
}

function errorHandler(options = {}) {
  const { includeStack = process.env.NODE_ENV !== 'production' } = options;

  return (err, req, res, next) => {
    const status = err.statusCode || 500;

    if (!err.isOperational) console.error(err.stack); // log the unexpected

    const body = { error: { message: err.message, code: err.code || 'INTERNAL_ERROR' } };
    if (err instanceof ValidationError) body.error.details = err.details;
    if (includeStack) body.error.stack = err.stack?.split('\n');

    res.status(status).json(body);
  };
}

// In a handler β€” throw a rich error, forward with next()
app.get('/api/users/:id', async (req, res, next) => {
  try {
    const user = await db.findUser(req.params.id);
    if (!user) throw new NotFoundError('User');
    res.json(user);
  } catch (err) {
    next(err);
  }
});

app.use(errorHandler()); // LAST
flowchart TD A[Route handler throws] --> B{Error type?} B -->|ValidationError| C[400] B -->|NotFoundError| D[404] B -->|AppError| E[custom status] B -->|Unknown| F[500] C --> G[Format JSON response] D --> G E --> G F --> G G --> H{Production?} H -->|yes| I[Hide stack trace] H -->|no| J[Include stack trace] I --> K[Send response] J --> K

Validation & Rate Limiting

Validation middleware

A generic validator that runs a schema (here with joi) against part of the request and replaces it with the sanitised value, or returns a 400 with all the errors:

const Joi = require('joi');

function validate(schema, property = 'body') {
  return (req, res, next) => {
    const { error, value } = schema.validate(req[property], {
      abortEarly: false,  // collect all errors
      stripUnknown: true  // drop unexpected fields
    });

    if (error) {
      const details = error.details.map(d => ({
        field: d.path.join('.'),
        message: d.message
      }));
      return res.status(400).json({
        error: { message: 'Validation failed', code: 'VALIDATION_ERROR', details }
      });
    }

    req[property] = value; // validated + sanitised
    next();
  };
}

const createUser = Joi.object({
  username: Joi.string().alphanum().min(3).max(30).required(),
  email: Joi.string().email().required(),
  password: Joi.string().min(8).required()
});

app.post('/api/users', validate(createUser), (req, res) => {
  res.status(201).json({ message: 'User created', user: req.body });
});

Rate limiting

A minimal in-memory limiter shows the mechanics β€” a per-client counter within a rolling window. For production you'd back this with Redis (or use express-rate-limit), but the pattern is the same:

function rateLimiter(options = {}) {
  const {
    windowMs = 60 * 1000,
    maxRequests = 100,
    message = 'Too many requests, please try again later',
    keyGenerator = (req) => req.ip
  } = options;

  const hits = new Map();

  return (req, res, next) => {
    const key = keyGenerator(req);
    const now = Date.now();
    const record = hits.get(key);

    if (!record || now - record.start > windowMs) {
      hits.set(key, { count: 1, start: now });
      return next();
    }

    record.count += 1;
    if (record.count > maxRequests) {
      res.set('Retry-After', Math.ceil(windowMs / 1000));
      return res.status(429).json({ error: { message, code: 'RATE_LIMIT_EXCEEDED' } });
    }
    next();
  };
}

// Strict limit on login to blunt brute-force attacks
app.post('/api/login',
  rateLimiter({ windowMs: 15 * 60 * 1000, maxRequests: 5 }),
  (req, res) => { /* ... */ }
);
πŸ’‘ Analogy: A rate limiter is the bouncer at a busy club, counting how often each guest tries to enter within a set time and asking the over-eager ones to wait β€” keeping the venue from getting dangerously crowded.

Composition Techniques

As your library of middleware grows, compose it rather than repeating yourself.

Pipelines β€” an array of middleware

Express accepts arrays of middleware, so bundle a common sequence once and reuse it:

function apiPipeline(schema) {
  return [
    requestLogger(),
    rateLimiter({ maxRequests: 100 }),
    authenticate(),
    schema ? validate(schema) : (req, res, next) => next()
  ];
}

app.get('/api/users', apiPipeline(), (req, res) => { /* ... */ });
app.post('/api/users', apiPipeline(createUser), (req, res) => { /* ... */ });

Conditional application

Pick middleware based on environment:

if (process.env.NODE_ENV === 'development') {
  app.use(requestLogger({ includeBody: true }));
} else {
  app.use(requestLogger({ excludePaths: ['/health', '/metrics'] }));
}
flowchart TD A[Request] --> B{Environment?} B -->|development| C[Verbose logging + relaxed security] B -->|production| D[Minimal logging + strict security] C --> E[Application logic] D --> E E --> F[Response]

Testing Middleware

Test middleware two ways: in isolation with mocked req/res/next, and integrated through a real app with supertest. Here's the integration style with Jest:

const request = require('supertest');
const express = require('express');
const authenticate = require('../middleware/authenticate');

describe('authenticate middleware', () => {
  let app;

  beforeEach(() => {
    app = express();
    app.use(authenticate({ secret: 'test-secret' }));
    app.get('/protected', (req, res) => res.json({ user: req.user }));
  });

  test('401 when no token is provided', async () => {
    const res = await request(app).get('/protected');
    expect(res.status).toBe(401);
  });

  test('200 with a valid token', async () => {
    const jwt = require('jsonwebtoken');
    const token = jwt.sign({ id: 1, username: 'testuser' }, 'test-secret');
    const res = await request(app)
      .get('/protected')
      .set('Authorization', `Bearer ${token}`);
    expect(res.status).toBe(200);
    expect(res.body.user.username).toBe('testuser');
  });
});
πŸ’‘ Analogy: Testing middleware is factory quality control β€” you check a component alone with varied inputs, then check it works assembled with its neighbours, so no defective part reaches the finished product.

Hands-on Exercise

πŸ‹οΈ Build a Configurable Timing Middleware

Objective: Write a factory responseTime({ header }) that measures how long each request takes and, when enabled, adds an X-Response-Time header β€” then verify it with a test.

Requirements:

  1. Record a start time when the request enters.
  2. On the response's finish event, compute the elapsed milliseconds and log them.
  3. If header is truthy (default true), set X-Response-Time: <n>ms before the response is sent.
  4. Write one supertest test asserting the header is present.
πŸ’‘ Hint

Headers must be set before the body is sent, so you can't set them in finish. Instead, wrap res.end (or use res.setHeader just before your handler responds) β€” the simplest reliable approach is to override res.end to stamp the header, then call the original.

βœ… Sample solution
function responseTime(options = {}) {
  const { header = true } = options;

  return (req, res, next) => {
    const start = process.hrtime.bigint();
    const originalEnd = res.end;

    res.end = function (...args) {
      const ms = Number(process.hrtime.bigint() - start) / 1e6;
      if (header && !res.headersSent) {
        res.setHeader('X-Response-Time', `${ms.toFixed(1)}ms`);
      }
      console.log(`${req.method} ${req.path} - ${ms.toFixed(1)}ms`);
      return originalEnd.apply(this, args);
    };

    next();
  };
}

module.exports = responseTime;

// --- test ---
const request = require('supertest');
const express = require('express');
const responseTime = require('./responseTime');

test('adds X-Response-Time header', async () => {
  const app = express();
  app.use(responseTime());
  app.get('/', (req, res) => res.send('ok'));

  const res = await request(app).get('/');
  expect(res.headers['x-response-time']).toMatch(/ms$/);
});

Overriding res.end lets you stamp the header in the same tick the body is written, while res.headersSent guards against a double-write.

🎯 Quick Quiz

Question 1: In the factory pattern, how does the returned middleware access its configuration?

Question 2: A logged-in user without the required role hits an admin route. Which status is correct?

Question 3: Why mark expected errors with isOperational = true in the error class?

Summary & Quiz

πŸŽ‰ Key Takeaways

  • Write custom middleware for app-specific logic, cross-cutting concerns, and bespoke security.
  • The factory pattern (options β†’ returned middleware) makes middleware configurable and reusable via closures.
  • Keep authentication (who) and authorization (what) as separate middleware; use 401 vs 403 correctly.
  • A custom error class hierarchy plus one error handler yields consistent, safe responses.
  • Compose with pipelines and conditional application, and test both in isolation and end-to-end.

❌ Common pitfalls

  • Forgetting to call next() (or respond) β€” the request hangs.
  • Setting headers after the body is sent β€” guard with res.headersSent.
  • Hard-coding config instead of accepting options β€” kills reusability.
  • Leaking stack traces to clients in production.

πŸ“š Further Reading

πŸš€ What's Next?

With middleware mastered, the next lesson digs into advanced routing techniques β€” route parameters, chaining, modular routers, and patterns for organising a growing API.