Writing Custom Middleware in Express

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);
Note: Middleware can attach any property to 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.
Tip: Write middleware as small, focused functions with one responsibility each. A single 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.
Warning: Never trust data from 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')

🧠 Test Yourself

You write a protect middleware that decodes a JWT and calls User.findById(). In Express 4, if the database query throws an error, which statement is true?