π οΈ 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.
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' }));
π‘ 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
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'] }));
}
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:
- Record a start time when the request enters.
- On the response's
finishevent, compute the elapsed milliseconds and log them. - If
headeris truthy (defaulttrue), setX-Response-Time: <n>msbefore the response is sent. - Write one
supertesttest 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.