Episode 3 — NodeJS MongoDB Backend Architecture / 3.6 — Middleware in Express

3.6.f — Error Handling and Security Middleware

In one sentence: Express uses a special four-parameter middleware signature (err, req, res, next) for centralized error handling, and security middleware like helmet, cors, and rate limiting protects your application from common web vulnerabilities -- together they form the defensive backbone of any production server.

Navigation: <- 3.6.e Custom Middleware Patterns | 3.6 Overview ->


1. Error-Handling Middleware Signature

Express error-handling middleware is identified by its four parameters. This is not optional -- Express uses the argument count to distinguish error handlers from regular middleware.

// Regular middleware: 3 parameters
app.use((req, res, next) => { /* ... */ });

// Error-handling middleware: 4 parameters (MUST have all four)
app.use((err, req, res, next) => { /* ... */ });
ParameterPurpose
errThe error object passed via next(err)
reqThe request object
resThe response object
nextPasses to the next error handler (if you have multiple)

Critical rule: Even if you do not use next, you must include all four parameters in the signature. If you write (err, req, res) with only three, Express treats it as regular middleware and it will not catch errors.


2. Throwing Errors with next(err)

When something goes wrong in a middleware or route handler, call next(err) to skip all remaining regular middleware and jump directly to the error-handling middleware.

app.get('/api/users/:id', async (req, res, next) => {
  try {
    const user = await User.findById(req.params.id);

    if (!user) {
      // Create and pass an error
      const error = new Error('User not found');
      error.statusCode = 404;
      return next(error); // Jumps to error handler
    }

    res.json({ user });
  } catch (err) {
    next(err); // Database error -- jumps to error handler
  }
});

What next(err) does internally:

Regular middleware 1  --> next()
Regular middleware 2  --> next()
Route handler         --> next(err)  <-- Error occurs!
Regular middleware 3  --> SKIPPED
Regular middleware 4  --> SKIPPED
Error handler         --> (err, req, res, next)  <-- Catches it!

Without next(err): You can also throw inside synchronous code, and Express will catch it. But for async code, you must use next(err) or the asyncHandler wrapper (see 3.6.e).

// Synchronous -- throw works
app.get('/sync-error', (req, res) => {
  throw new Error('Something broke!'); // Express catches this
});

// Asynchronous -- throw does NOT work, use next(err)
app.get('/async-error', async (req, res, next) => {
  try {
    await someAsyncOperation();
  } catch (err) {
    next(err); // Must use next(err) for async errors
  }
});

3. Centralized Error Handler Pattern

Instead of handling errors in every route, create a single error handler that formats all error responses consistently.

Basic error handler

// Must be registered AFTER all routes and other middleware
app.use((err, req, res, next) => {
  console.error('Error:', err.message);
  console.error('Stack:', err.stack);

  const statusCode = err.statusCode || 500;
  const message = err.message || 'Internal Server Error';

  res.status(statusCode).json({
    error: {
      message: message,
      status: statusCode
    }
  });
});

Production-ready error handler

// Custom error class
class AppError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.statusCode = statusCode;
    this.isOperational = true; // Distinguishes expected vs unexpected errors
    Error.captureStackTrace(this, this.constructor);
  }
}

// Usage in routes
app.get('/api/users/:id', asyncHandler(async (req, res) => {
  const user = await User.findById(req.params.id);
  if (!user) {
    throw new AppError('User not found', 404);
  }
  res.json({ user });
}));

app.post('/api/users', asyncHandler(async (req, res) => {
  if (!req.body.email) {
    throw new AppError('Email is required', 400);
  }
  const user = await User.create(req.body);
  res.status(201).json({ user });
}));

// Centralized handler
app.use((err, req, res, next) => {
  // Default values
  err.statusCode = err.statusCode || 500;
  err.message = err.message || 'Internal Server Error';

  // Log all errors
  console.error(`[ERROR] ${req.method} ${req.originalUrl}:`, {
    message: err.message,
    statusCode: err.statusCode,
    stack: err.stack
  });

  // Development: send full error details
  if (process.env.NODE_ENV === 'development') {
    return res.status(err.statusCode).json({
      error: {
        message: err.message,
        status: err.statusCode,
        stack: err.stack
      }
    });
  }

  // Production: hide internal details
  if (err.isOperational) {
    // Expected error -- safe to show message
    return res.status(err.statusCode).json({
      error: {
        message: err.message,
        status: err.statusCode
      }
    });
  }

  // Unexpected error -- don't leak details
  res.status(500).json({
    error: {
      message: 'Something went wrong',
      status: 500
    }
  });
});

Handling specific error types

app.use((err, req, res, next) => {
  // MongoDB duplicate key error
  if (err.code === 11000) {
    const field = Object.keys(err.keyValue)[0];
    return res.status(409).json({
      error: {
        message: `Duplicate value for ${field}`,
        status: 409
      }
    });
  }

  // MongoDB validation error
  if (err.name === 'ValidationError') {
    const messages = Object.values(err.errors).map(e => e.message);
    return res.status(400).json({
      error: {
        message: 'Validation failed',
        details: messages,
        status: 400
      }
    });
  }

  // JWT errors
  if (err.name === 'JsonWebTokenError') {
    return res.status(401).json({
      error: { message: 'Invalid token', status: 401 }
    });
  }

  if (err.name === 'TokenExpiredError') {
    return res.status(401).json({
      error: { message: 'Token expired', status: 401 }
    });
  }

  // Default
  const statusCode = err.statusCode || 500;
  res.status(statusCode).json({
    error: {
      message: err.isOperational ? err.message : 'Internal server error',
      status: statusCode
    }
  });
});

4. Multiple Error Handlers

You can chain error handlers. Each can handle specific error types and pass unhandled ones to the next.

// Handler 1: Log all errors
app.use((err, req, res, next) => {
  console.error(`[${new Date().toISOString()}] Error:`, err.message);
  next(err); // Pass to next error handler
});

// Handler 2: Handle validation errors
app.use((err, req, res, next) => {
  if (err.name === 'ValidationError') {
    return res.status(400).json({ error: err.message });
  }
  next(err); // Not a validation error -- pass along
});

// Handler 3: Handle auth errors
app.use((err, req, res, next) => {
  if (err.statusCode === 401 || err.statusCode === 403) {
    return res.status(err.statusCode).json({ error: err.message });
  }
  next(err);
});

// Handler 4: Catch-all
app.use((err, req, res, next) => {
  res.status(500).json({ error: 'Internal server error' });
});

5. 404 Handler -- Not an Error Handler

A 404 handler is a regular middleware (3 parameters) placed after all routes. It catches requests that did not match any route.

// All routes defined above...

// 404 -- regular middleware, NOT error middleware
app.use((req, res, next) => {
  res.status(404).json({
    error: {
      message: `Cannot ${req.method} ${req.originalUrl}`,
      status: 404
    }
  });
});

// Error handler -- placed LAST
app.use((err, req, res, next) => {
  // Handle actual errors
  res.status(err.statusCode || 500).json({
    error: { message: err.message }
  });
});

6. helmet -- Security Headers

helmet is a collection of middleware that sets HTTP response headers to protect against well-known web vulnerabilities.

npm install helmet
const helmet = require('helmet');
app.use(helmet());

What headers helmet sets and why

HeaderWhat It DoesAttack It Prevents
Content-Security-PolicyControls which resources (scripts, styles, images) the browser can loadXSS -- prevents injected scripts from executing
X-Content-Type-Options: nosniffStops the browser from guessing (MIME-sniffing) file typesMIME confusion attacks -- prevents .txt from being executed as JS
X-Frame-Options: SAMEORIGINPrevents your page from being embedded in an iframe on other sitesClickjacking -- invisible overlays tricking users
Strict-Transport-SecurityForces browsers to use HTTPS for future requestsProtocol downgrade attacks -- man-in-the-middle on HTTP
X-XSS-Protection: 0Disables the browser's built-in XSS filter (it was buggy)Ironically, the old filter itself caused vulnerabilities
X-DNS-Prefetch-Control: offPrevents browsers from pre-resolving DNS for external linksPrivacy leakage -- reveals which links are on the page
X-Download-Options: noopenPrevents IE from opening downloads directlyIE-specific execution attack
Referrer-PolicyControls how much referrer info is sent with requestsInformation leakage in URLs
X-Permitted-Cross-Domain-PoliciesRestricts Adobe Flash/Reader cross-domain accessFlash-based attacks

Custom helmet configuration

app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'", "https://cdn.example.com"],
      styleSrc: ["'self'", "'unsafe-inline'"],
      imgSrc: ["'self'", "data:", "https://images.example.com"],
      connectSrc: ["'self'", "https://api.example.com"],
      fontSrc: ["'self'", "https://fonts.googleapis.com"],
      objectSrc: ["'none'"],
      upgradeInsecureRequests: [],
    },
  },
  crossOriginEmbedderPolicy: false,  // Disable if embedding external resources
  hsts: {
    maxAge: 31536000,    // 1 year
    includeSubDomains: true,
    preload: true
  }
}));

Disable specific helmet features

app.use(helmet({
  contentSecurityPolicy: false,  // Disable CSP (if it breaks your frontend)
  crossOriginOpenerPolicy: false
}));

7. cors Configuration

CORS (Cross-Origin Resource Sharing) controls which domains can make requests to your API.

npm install cors

Why CORS exists

Browsers enforce the Same-Origin Policy: a page at https://myapp.com cannot make fetch/XHR requests to https://api.example.com unless the API explicitly allows it via CORS headers.

Basic configurations

const cors = require('cors');

// Allow ALL origins (only for development)
app.use(cors());

// Allow one specific origin
app.use(cors({
  origin: 'https://myapp.com'
}));

// Allow multiple origins
app.use(cors({
  origin: ['https://myapp.com', 'https://admin.myapp.com']
}));

// Dynamic origin (check against database or list)
app.use(cors({
  origin: (origin, callback) => {
    const allowedOrigins = [
      'https://myapp.com',
      'https://staging.myapp.com'
    ];

    // Allow requests with no origin (mobile apps, curl, Postman)
    if (!origin || allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  }
}));

Full CORS configuration

app.use(cors({
  origin: 'https://myapp.com',
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
  exposedHeaders: ['X-Total-Count', 'X-Request-ID'],
  credentials: true,      // Allow cookies and Authorization headers
  maxAge: 86400,           // Cache preflight response for 24 hours
  preflightContinue: false,
  optionsSuccessStatus: 204
}));
OptionWhat It Does
originWhich origins can access the API
methodsWhich HTTP methods are allowed
allowedHeadersWhich headers the client can send
exposedHeadersWhich response headers the client can read
credentialsWhether cookies/auth headers are allowed
maxAgeHow long (seconds) to cache preflight results

CORS preflight requests (OPTIONS)

For "non-simple" requests (PUT, DELETE, custom headers, JSON content-type), the browser sends a preflight OPTIONS request first to check permissions.

1. Browser: OPTIONS /api/users (preflight)
   Headers: Origin, Access-Control-Request-Method, Access-Control-Request-Headers

2. Server responds with CORS headers:
   Access-Control-Allow-Origin: https://myapp.com
   Access-Control-Allow-Methods: GET, POST, PUT, DELETE
   Access-Control-Allow-Headers: Content-Type, Authorization
   Access-Control-Max-Age: 86400

3. Browser: If allowed, sends actual request
   POST /api/users
   Headers: Content-Type: application/json, Authorization: Bearer ...

4. Server responds with data + CORS headers

The cors() middleware handles preflight automatically. You can also handle it manually for specific routes:

// Handle preflight for a specific route
app.options('/api/users', cors());

// Handle preflight for all routes
app.options('*', cors());

8. express-rate-limit -- Preventing Abuse

Rate limiting restricts how many requests a client can make in a time window.

npm install express-rate-limit

Global rate limit

const rateLimit = require('express-rate-limit');

const globalLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,   // 15 minutes
  max: 100,                     // 100 requests per window per IP
  message: {
    error: 'Too many requests',
    retryAfter: '15 minutes'
  },
  standardHeaders: true,        // Send RateLimit-* headers
  legacyHeaders: false,         // Disable X-RateLimit-* headers
});

app.use(globalLimiter);

Route-specific limits

// Strict limit for login (prevents brute force)
const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,    // 15 minutes
  max: 5,                       // 5 attempts
  message: {
    error: 'Too many login attempts',
    message: 'Try again in 15 minutes'
  },
  skipSuccessfulRequests: true   // Don't count successful logins
});

app.post('/auth/login', loginLimiter, loginHandler);

// Moderate limit for API
const apiLimiter = rateLimit({
  windowMs: 60 * 1000,          // 1 minute
  max: 30,                       // 30 requests per minute
  message: { error: 'API rate limit exceeded' }
});

app.use('/api', apiLimiter);

// Strict limit for password reset
const resetLimiter = rateLimit({
  windowMs: 60 * 60 * 1000,     // 1 hour
  max: 3,                        // 3 requests per hour
  message: { error: 'Too many reset attempts' }
});

app.post('/auth/forgot-password', resetLimiter, forgotPasswordHandler);

Rate limit headers sent to the client

RateLimit-Limit: 100           -- Max requests allowed
RateLimit-Remaining: 87        -- Requests remaining in window
RateLimit-Reset: 1697392000    -- When the window resets (Unix timestamp)

9. CSRF Protection Concepts

CSRF (Cross-Site Request Forgery) tricks authenticated users into making unwanted requests. Example: a malicious site submits a form to your API using the user's existing cookies.

How CSRF works

1. User logs into your-bank.com (gets session cookie)
2. User visits evil-site.com
3. evil-site.com has: <form action="your-bank.com/transfer" method="POST">
4. Browser sends the form WITH the user's bank cookie
5. Bank processes the transfer -- user didn't intend this!

Protection strategies

StrategyHow It Works
CSRF tokensServer generates a unique token per session; forms must include it; API rejects requests without it
SameSite cookiesSet-Cookie: session=abc; SameSite=Strict prevents cookies from being sent on cross-origin requests
Check Origin/Referer headersReject requests where the Origin header doesn't match your domain
Custom request headersRequire X-Requested-With: XMLHttpRequest -- simple forms cannot set custom headers

SameSite cookies (modern approach)

// When setting cookies, use SameSite
res.cookie('session', token, {
  httpOnly: true,       // Not accessible via JavaScript
  secure: true,         // HTTPS only
  sameSite: 'strict',   // Only sent for same-site requests
  maxAge: 24 * 60 * 60 * 1000 // 1 day
});

CSRF token approach (for server-rendered forms)

npm install csurf     # Note: csurf is deprecated; consider csrf-csrf or lusca
npm install csrf-csrf # Modern alternative
// Simplified concept (not production code for deprecated csurf)
// Modern apps using JWT + SPA architecture often skip CSRF tokens
// because they don't use cookies for auth -- Bearer tokens in headers
// are not automatically attached by the browser

// If you use cookie-based sessions, implement CSRF protection:
const { doubleCsrf } = require('csrf-csrf');

const { doubleCsrfProtection } = doubleCsrf({
  getSecret: () => process.env.CSRF_SECRET,
  cookieName: '__csrf',
  cookieOptions: { sameSite: 'strict', secure: true }
});

app.use(doubleCsrfProtection);

10. Security Best Practices Checklist

#PracticeImplementation
1Use helmetapp.use(helmet()) -- sets secure headers
2Configure CORS strictlySpecify exact origins, not * in production
3Rate limit all endpointsGlobal limiter + stricter limits on auth routes
4Validate all inputNever trust req.body, req.params, req.query
5Use HTTPSEnforce via Strict-Transport-Security header
6Set secure cookieshttpOnly, secure, sameSite flags
7Don't leak error detailsShow generic messages in production
8Limit request body sizeexpress.json({ limit: '10kb' })
9Use parameterized queriesNever concatenate user input into database queries
10Keep dependencies updatednpm audit regularly
11Remove X-Powered-Byhelmet does this, or app.disable('x-powered-by')
12Log security eventsFailed logins, rate limit hits, auth failures

11. Complete Error Handling and Security Setup

const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const morgan = require('morgan');
const rateLimit = require('express-rate-limit');

const app = express();

// =============================================
// SECURITY MIDDLEWARE
// =============================================

// 1. Security headers
app.use(helmet());

// 2. CORS
app.use(cors({
  origin: process.env.CLIENT_URL || 'http://localhost:5173',
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization']
}));

// 3. Global rate limit
app.use(rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 100,
  standardHeaders: true,
  legacyHeaders: false,
  message: { error: 'Rate limit exceeded. Try again later.' }
}));

// =============================================
// BODY PARSING
// =============================================

app.use(express.json({ limit: '10kb' }));
app.use(express.urlencoded({ extended: true, limit: '10kb' }));

// =============================================
// LOGGING
// =============================================

if (process.env.NODE_ENV === 'development') {
  app.use(morgan('dev'));
} else {
  app.use(morgan('combined'));
}

// =============================================
// ROUTES
// =============================================

// Strict rate limit on auth routes
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 10,
  message: { error: 'Too many auth attempts' }
});

app.use('/api/auth', authLimiter);

app.post('/api/auth/login', (req, res) => {
  // Login logic...
  res.json({ token: 'jwt-token' });
});

app.get('/api/users', (req, res) => {
  res.json({ users: [] });
});

app.get('/api/users/:id', (req, res, next) => {
  try {
    const id = parseInt(req.params.id, 10);
    if (isNaN(id)) {
      const error = new Error('Invalid user ID');
      error.statusCode = 400;
      throw error;
    }
    // Simulate user not found
    const error = new Error('User not found');
    error.statusCode = 404;
    throw error;
  } catch (err) {
    next(err);
  }
});

// =============================================
// 404 HANDLER
// =============================================

app.use((req, res) => {
  res.status(404).json({
    error: {
      message: `Cannot ${req.method} ${req.originalUrl}`,
      status: 404
    }
  });
});

// =============================================
// ERROR HANDLER (must be last, must have 4 params)
// =============================================

app.use((err, req, res, next) => {
  // Log the error
  console.error(`[ERROR] ${new Date().toISOString()} ${req.method} ${req.originalUrl}`);
  console.error(`  Message: ${err.message}`);
  if (process.env.NODE_ENV === 'development') {
    console.error(`  Stack: ${err.stack}`);
  }

  // Determine status code
  const statusCode = err.statusCode || 500;

  // Build response
  const response = {
    error: {
      message: statusCode === 500 && process.env.NODE_ENV === 'production'
        ? 'Internal server error'
        : err.message,
      status: statusCode
    }
  };

  // Include stack trace in development
  if (process.env.NODE_ENV === 'development') {
    response.error.stack = err.stack;
  }

  res.status(statusCode).json(response);
});

// =============================================
// START SERVER
// =============================================

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
  console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
});

12. Error Handler Placement -- Visual Summary

app.use(helmet())                 <-- Security (first)
app.use(cors())                   <-- CORS
app.use(rateLimit())              <-- Rate limiting
app.use(express.json())           <-- Body parsing
app.use(morgan())                 <-- Logging
app.use(customMiddleware)         <-- Custom middleware
  |
  v
app.get('/route1', handler)       <-- Routes
app.post('/route2', handler)
app.use('/api', router)
  |
  v
app.use((req, res) => { 404 })   <-- 404 handler (regular middleware)
  |
  v
app.use((err, req, res, next) => { ... })  <-- Error handler (ALWAYS LAST)

Key Takeaways

  1. Error-handling middleware has exactly four parameters: (err, req, res, next). Express uses the argument count to identify it.
  2. next(err) skips all regular middleware and jumps to the error handler. For async code, always wrap errors with next(err) or use asyncHandler.
  3. Centralized error handling means one place for consistent error formatting, logging, and environment-aware responses.
  4. helmet() sets security headers (CSP, HSTS, X-Frame-Options, etc.) with a single line.
  5. cors() must be configured with specific origins in production -- never use * with credentials: true.
  6. Rate limiting prevents brute-force and DDoS attacks -- use stricter limits on auth routes.
  7. Error handlers must be registered last, after all routes and the 404 handler.

Explain-It Challenge

Explain without notes:

  1. Why does Express error-handling middleware require exactly four parameters?
  2. What is the difference between a 404 handler and an error handler in Express?
  3. Name five HTTP headers that helmet sets and explain what attack each prevents.
  4. A user on https://frontend.com cannot call your API at https://api.backend.com. What is the problem and how do you fix it?
  5. Design a rate-limiting strategy for an app with public endpoints, authenticated API endpoints, and a login route.

Navigation: <- 3.6.e Custom Middleware Patterns | 3.6 Overview ->