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) => { /* ... */ });
| Parameter | Purpose |
|---|---|
err | The error object passed via next(err) |
req | The request object |
res | The response object |
next | Passes 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
| Header | What It Does | Attack It Prevents |
|---|---|---|
Content-Security-Policy | Controls which resources (scripts, styles, images) the browser can load | XSS -- prevents injected scripts from executing |
X-Content-Type-Options: nosniff | Stops the browser from guessing (MIME-sniffing) file types | MIME confusion attacks -- prevents .txt from being executed as JS |
X-Frame-Options: SAMEORIGIN | Prevents your page from being embedded in an iframe on other sites | Clickjacking -- invisible overlays tricking users |
Strict-Transport-Security | Forces browsers to use HTTPS for future requests | Protocol downgrade attacks -- man-in-the-middle on HTTP |
X-XSS-Protection: 0 | Disables the browser's built-in XSS filter (it was buggy) | Ironically, the old filter itself caused vulnerabilities |
X-DNS-Prefetch-Control: off | Prevents browsers from pre-resolving DNS for external links | Privacy leakage -- reveals which links are on the page |
X-Download-Options: noopen | Prevents IE from opening downloads directly | IE-specific execution attack |
Referrer-Policy | Controls how much referrer info is sent with requests | Information leakage in URLs |
X-Permitted-Cross-Domain-Policies | Restricts Adobe Flash/Reader cross-domain access | Flash-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
}));
| Option | What It Does |
|---|---|
origin | Which origins can access the API |
methods | Which HTTP methods are allowed |
allowedHeaders | Which headers the client can send |
exposedHeaders | Which response headers the client can read |
credentials | Whether cookies/auth headers are allowed |
maxAge | How 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
| Strategy | How It Works |
|---|---|
| CSRF tokens | Server generates a unique token per session; forms must include it; API rejects requests without it |
| SameSite cookies | Set-Cookie: session=abc; SameSite=Strict prevents cookies from being sent on cross-origin requests |
| Check Origin/Referer headers | Reject requests where the Origin header doesn't match your domain |
| Custom request headers | Require 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
| # | Practice | Implementation |
|---|---|---|
| 1 | Use helmet | app.use(helmet()) -- sets secure headers |
| 2 | Configure CORS strictly | Specify exact origins, not * in production |
| 3 | Rate limit all endpoints | Global limiter + stricter limits on auth routes |
| 4 | Validate all input | Never trust req.body, req.params, req.query |
| 5 | Use HTTPS | Enforce via Strict-Transport-Security header |
| 6 | Set secure cookies | httpOnly, secure, sameSite flags |
| 7 | Don't leak error details | Show generic messages in production |
| 8 | Limit request body size | express.json({ limit: '10kb' }) |
| 9 | Use parameterized queries | Never concatenate user input into database queries |
| 10 | Keep dependencies updated | npm audit regularly |
| 11 | Remove X-Powered-By | helmet does this, or app.disable('x-powered-by') |
| 12 | Log security events | Failed 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
- Error-handling middleware has exactly four parameters:
(err, req, res, next). Express uses the argument count to identify it. next(err)skips all regular middleware and jumps to the error handler. For async code, always wrap errors withnext(err)or useasyncHandler.- Centralized error handling means one place for consistent error formatting, logging, and environment-aware responses.
helmet()sets security headers (CSP, HSTS, X-Frame-Options, etc.) with a single line.cors()must be configured with specific origins in production -- never use*withcredentials: true.- Rate limiting prevents brute-force and DDoS attacks -- use stricter limits on auth routes.
- Error handlers must be registered last, after all routes and the 404 handler.
Explain-It Challenge
Explain without notes:
- Why does Express error-handling middleware require exactly four parameters?
- What is the difference between a 404 handler and an error handler in Express?
- Name five HTTP headers that
helmetsets and explain what attack each prevents. - A user on
https://frontend.comcannot call your API athttps://api.backend.com. What is the problem and how do you fix it? - 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 ->