Built-in and third-party middleware handle the common cases. Custom middleware is what makes your Express API uniquely yours — the JWT authentication guard that protects private routes, the role-based access check that distinguishes regular users from admins, the request logger that records exactly what you need, and the input validator that ensures data is safe before it reaches your database. Writing good custom middleware requires understanding the req, res, next pattern deeply and knowing how to make middleware composable, testable, and reusable. This lesson covers all of that through four practical examples you will use throughout the series.
Custom Middleware Structure
// A custom middleware is any function that follows this signature:
const myMiddleware = (req, res, next) => {
// 1. Read from req — method, path, headers, body, params, query
// 2. Validate, transform, or authenticate
// 3. Optionally attach data to req for downstream handlers:
// req.user = decodedToken;
// req.startTime = Date.now();
// 4. Call next() to continue, next(err) to error out, or res.json() to respond
};
// Factory function — returns middleware configured by parameters
const requireRole = (role) => (req, res, next) => {
if (req.user?.role !== role) {
return next(new AppError('Insufficient permissions', 403));
}
next();
};
// Usage:
app.delete('/api/users/:id', protect, requireRole('admin'), deleteUser);
req — this is the standard way to pass data from middleware to downstream route handlers. The protect JWT middleware attaches req.user. A request ID middleware might attach req.requestId. An upload middleware attaches req.file. Downstream handlers read these properties without needing to know how they were set.protect middleware should only verify the JWT and attach req.user. A separate requireRole('admin') middleware checks the role. This way you can compose them: protect alone for authentication, protect + requireRole for role-based access. Combining responsibilities into one middleware makes it impossible to reuse independently.req.body, req.params, or req.query in your middleware without validation. A malicious user can send any value in these fields. In particular, never set req.user from req.body.user — always decode it from a verified JWT. Setting roles or permissions from client-supplied data is a critical security vulnerability.Example 1 — Request Logger Middleware
// server/src/middleware/requestLogger.js
const requestLogger = (req, res, next) => {
const start = Date.now();
// Attach a unique ID to each request for log correlation
req.requestId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
// Log when response finishes (not at request start — we want status code too)
res.on('finish', () => {
const duration = Date.now() - start;
const logLine = [
new Date().toISOString(),
req.requestId,
req.method,
req.originalUrl,
res.statusCode,
`${duration}ms`,
req.ip,
].join(' | ');
if (res.statusCode >= 400) {
console.error(logLine);
} else {
console.log(logLine);
}
});
next();
};
module.exports = requestLogger;
Example 2 — JWT Authentication Middleware
// server/src/middleware/auth.js
const jwt = require('jsonwebtoken');
const User = require('../models/User');
const AppError = require('../utils/AppError');
const protect = async (req, res, next) => {
try {
// 1. Extract token from Authorization header
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return next(new AppError('No token provided — please log in', 401));
}
const token = authHeader.split(' ')[1]; // 'Bearer ' → ''
// 2. Verify the token signature and expiry
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Throws JsonWebTokenError if invalid
// Throws TokenExpiredError if expired
// 3. Check the user still exists in the database
const user = await User.findById(decoded.id).select('-password');
if (!user) {
return next(new AppError('User belonging to this token no longer exists', 401));
}
// 4. Attach user to request for downstream handlers
req.user = user;
next();
} catch (err) {
next(err); // JsonWebTokenError and TokenExpiredError handled by errorHandler
}
};
module.exports = protect;
Example 3 — Role-Based Access Control Middleware
// server/src/middleware/authorize.js
const AppError = require('../utils/AppError');
// Factory — returns middleware that allows only specified roles
// Usage: authorize('admin') or authorize('admin', 'editor')
const authorize = (...roles) => (req, res, next) => {
// protect middleware must run first to populate req.user
if (!req.user) {
return next(new AppError('Not authenticated', 401));
}
if (!roles.includes(req.user.role)) {
return next(new AppError(
`Role '${req.user.role}' is not authorised to access this route`,
403
));
}
next();
};
module.exports = authorize;
// Usage in routes:
const protect = require('../middleware/auth');
const authorize = require('../middleware/authorize');
// Admin only
router.delete('/api/users/:id', protect, authorize('admin'), deleteUser);
// Admin or editor
router.put('/api/posts/:id', protect, authorize('admin', 'editor'), updatePost);
// All authenticated users (any role)
router.get('/api/profile', protect, getProfile);
Example 4 — Input Validation Middleware
// server/src/middleware/validatePost.js
const AppError = require('../utils/AppError');
const validateCreatePost = (req, res, next) => {
const { title, body } = req.body;
const errors = [];
// Required field checks
if (!title || typeof title !== 'string' || title.trim().length === 0) {
errors.push('title is required and must be a non-empty string');
} else if (title.trim().length < 3 || title.trim().length > 200) {
errors.push('title must be between 3 and 200 characters');
}
if (!body || typeof body !== 'string' || body.trim().length === 0) {
errors.push('body is required and must be a non-empty string');
} else if (body.trim().length < 10) {
errors.push('body must be at least 10 characters long');
}
// Optional field type checks
if (req.body.tags !== undefined && !Array.isArray(req.body.tags)) {
errors.push('tags must be an array');
}
if (errors.length > 0) {
return next(new AppError(errors.join(', '), 400));
}
// Sanitise and attach clean data so controller does not need to trim/sanitise
req.validatedBody = {
title: title.trim(),
body: body.trim(),
tags: Array.isArray(req.body.tags) ? req.body.tags : [],
};
next();
};
module.exports = { validateCreatePost };
// Usage:
router.post('/', protect, validateCreatePost, createPost);
// createPost controller reads req.validatedBody instead of raw req.body
Composing Middleware on Routes
// Middleware runs left to right in the array
// Each must call next() to proceed, or respond to stop the chain
router.route('/')
.get(getAllPosts) // public
.post(protect, validateCreatePost, createPost); // auth → validate → create
router.route('/:id')
.get(validateObjectId('id'), getPostById) // validate ID only
.put(protect, authorize('admin','editor'), validateObjectId('id'), updatePost)
.delete(protect, authorize('admin'), validateObjectId('id'), deletePost);
// Request flow for DELETE /api/posts/:id:
// 1. protect → verify JWT, attach req.user
// 2. authorize → check req.user.role === 'admin'
// 3. validateObjectId → check :id is valid MongoDB ObjectId
// 4. deletePost → find and delete the post from MongoDB
// 5. res.status(204).end() — response sent
Common Mistakes
Mistake 1 — Reading req.user before protect middleware runs
❌ Wrong — using req.user in a route handler without protect middleware:
router.get('/profile', (req, res) => {
const user = req.user; // undefined — protect was not added to this route
res.json({ user });
});
✅ Correct — always add protect before any handler that reads req.user:
router.get('/profile', protect, getProfile); // protect runs first ✓
Mistake 2 — Async middleware without try/catch in Express 4
❌ Wrong — an unhandled async error in Express 4 does not reach the error handler:
const protect = async (req, res, next) => {
const user = await User.findById(decoded.id); // if this throws — unhandled!
req.user = user;
next();
};
✅ Correct — wrap async middleware in try/catch and call next(err):
const protect = async (req, res, next) => {
try {
const user = await User.findById(decoded.id);
req.user = user;
next();
} catch (err) {
next(err); // forwards to global error handler ✓
}
};
Mistake 3 — Mutating req.body instead of using a separate property
❌ Wrong — overwriting req.body in validation middleware causes confusion downstream:
req.body = { title: title.trim(), body: body.trim() }; // replaces original body
✅ Correct — attach validated data as a new req property and keep req.body intact:
req.validatedBody = { title: title.trim(), body: body.trim() }; // new property ✓
Quick Reference
| Pattern | Code |
|---|---|
| Basic middleware | (req, res, next) => { ...; next(); } |
| Async middleware | async (req, res, next) => { try { ... } catch(e) { next(e) } } |
| Factory middleware | const mw = (param) => (req, res, next) => { ... } |
| Attach data to req | req.user = decoded; next() |
| Block with error | return next(new AppError('msg', 401)) |
| Compose on route | router.post('/', protect, validate, handler) |
| Role check | authorize('admin', 'editor') |