StackPractices
intermediate By Mathias Paulenko

Middleware: HTTP Request Interceptors

How to implement request/response middleware for logging, auth, and error handling across Python, JavaScript, and Java.

Topics: api

Overview

Every request to your API needs the same plumbing: log it, check the auth token, add CORS headers, time how long it takes, catch whatever blows up. Middleware is the layer that does that plumbing — functions that sit between the incoming request and your route handler, running in a pipeline before (and sometimes after) the handler executes.

Each middleware in the chain can inspect or modify the request, abort it early (a 401 from an auth check), or pass control to the next layer with next(). The order you register them in is the order they run — which is why the classic bug is putting the error handler before the routes. This recipe builds the three most common layers — logging, CORS and auth — plus the error handler that catches everything downstream, in three frameworks: FastAPI (Python), Express (Node.js) and Spring Boot (Java).

When to Use

Use this recipe when:

  • Enforcing authentication before route handlers execute. See JWT Authentication for auth patterns.
  • Logging all incoming requests and response times. See Logging for structured logging.
  • Adding CORS headers or security headers to every response. See Handle CORS for CORS configuration.
  • Validating request bodies or query parameters in one place instead of inside every handler.
  • Implementing rate limiting or request throttling. See Rate Limiting with Redis for Redis-based rate limiting.

This recipe shows the framework-agnostic shape of middleware — the Express and FastAPI chains below are runnable in the companion repository. For Express-specific compositions — async wrappers, mount-path scoping, error boundaries — see Express middleware patterns.

Solution

Python (FastAPI)

from fastapi import Request, HTTPException
from fastapi.middleware.cors import CORSMiddleware
import time

# Custom middleware
@app.middleware("http")
async def log_requests(request: Request, call_next):
    start = time.time()
    response = await call_next(request)
    duration = time.time() - start
    print(f"{request.method} {request.url.path} - {response.status_code} ({duration:.3f}s)")
    return response

# CORS middleware
app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://example.com"],
    allow_methods=["GET", "POST"],
)

# Auth dependency (used as middleware in routes)
async def verify_token(request: Request):
    token = request.headers.get("Authorization")
    if not token or not token.startswith("Bearer "):
        raise HTTPException(status_code=401, detail="Missing token")
    return token

@app.get("/protected", dependencies=[Depends(verify_token)])
async def protected():
    return {"message": "Secret data"}

JavaScript (Express)

const express = require('express');
const app = express();

// Logging middleware
app.use((req, res, next) => {
  const start = Date.now();
  res.on('finish', () => {
    const duration = Date.now() - start;
    console.log(`${req.method} ${req.path} - ${res.statusCode} (${duration}ms)`);
  });
  next();
});

// Auth middleware
function authMiddleware(req, res, next) {
  const token = req.headers.authorization;
  if (!token || !token.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Unauthorized' });
  }
  req.user = verifyToken(token); // your token verification logic
  next();
}

app.use('/api/protected', authMiddleware);

// Error handling middleware (must be last)
app.use((err, req, res, next) => {
  console.error(err);
  res.status(err.status || 500).json({ error: err.message || 'Internal Server Error' });
});

Java (Spring Boot)

@Component
public class LoggingInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) {
        req.setAttribute("startTime", System.currentTimeMillis());
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest req, HttpServletResponse res, Object handler, Exception ex) {
        long start = (Long) req.getAttribute("startTime");
        long duration = System.currentTimeMillis() - start;
        System.out.println(req.getMethod() + " " + req.getRequestURI() + " - " + res.getStatus() + " (" + duration + "ms)");
    }
}

// Register interceptor
@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Autowired
    private LoggingInterceptor loggingInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(loggingInterceptor).addPathPatterns("/api/**");
    }
}

Explanation

The pipeline is the whole idea. A request enters at the outermost layer and travels inward through each middleware until it reaches the handler; the response then travels back out through the same layers in reverse:

sequenceDiagram diagram: participant Client

When auth rejects the request, next() is never called and the handler never runs — that’s the “fail fast” behavior that makes middleware the right home for auth, validation and rate limiting.

Each framework exposes hooks at different points in the pipeline:

  • Before the handler (Express app.use, FastAPI call_next, Spring preHandle): inspect, modify or block the request.
  • On the way out (Express res.on('finish'), Spring postHandle): add headers, log, record metrics.
  • After the response is sent (Spring afterCompletion): cleanup, async logging.
  • Error middleware (Express’s four-argument (err, req, res, next) signature): catches anything thrown downstream. Express identifies it purely by argument count — a three-arg function is never called for errors. It must be registered last.

Middleware vs decorators and guards: middleware runs for every request matching its mount path; a decorator or dependency (FastAPI Depends, NestJS guard, Spring @PreAuthorize) attaches to a single route. Reach for middleware when the concern is genuinely cross-cutting — logging, CORS, correlation IDs — and for per-endpoint rules use a dependency or guard instead. A per-route auth check done as global middleware forces you to maintain an exception list, and that’s the part everyone forgets.

A concrete example of why order matters: browsers send an OPTIONS preflight before any cross-origin request — and preflights carry no credentials. Register auth before CORS and every preflight dies on a 401 before the CORS headers ever get attached; the browser reports a “CORS error” that isn’t really a CORS problem. Put CORS first and the preflight gets its headers and short-circuits, while real requests still hit auth. Most “mysterious” middleware bugs trace back to this kind of ordering assumption.

What Works

  • Register in the order the request should flow: logging and correlation IDs first (so everything downstream gets traced), then CORS and security headers, then auth, then routes — error handler dead last.
  • Fail fast: an auth middleware returning 401 saves the handler from running at all. Cheap rejection beats expensive processing.
  • Don’t swallow errors: in Express, wrap async middleware in try/catch and forward with next(err) — an unhandled async rejection bypasses the error handler entirely.
  • Stay stateless: request-scoped data in module-level variables bleeds between concurrent requests. Attach to req (Express), request.state (FastAPI) or request attributes (Spring) instead.
  • Use the framework’s built-ins for the boring stuff: Express’s helmet and morgan, FastAPI’s CORSMiddleware and GZipMiddleware, Spring’s WebMvcConfigurer hooks are all battle-tested — write custom middleware only where they stop.
  • Inject, don’t construct: in Spring and FastAPI, inject services into the interceptor rather than building them inside it — cheaper and testable.

Common Mistakes

  • Forgetting next(): the request hangs forever — no response, no error, just a timeout. Still the most common Express middleware bug by a wide margin.
  • Error handler not last: register the four-arg error middleware before your routes and it silently never fires.
  • Mutating req with untrusted data: assigning req.user from query params or unvalidated headers overwrites trusted state with attacker input.
  • Heavy synchronous work in the chain: middleware runs on every request — a blocking CPU spike stalls every other request in the process.
  • Calling next() twice: usually from a branch that forgot to return after calling it once. In older Express versions it crashes the process; newer ones produce corrupted responses.

Troubleshooting

  • Requests hang in Express: a middleware upstream isn’t calling next(). Bisect by commenting out app.use lines, or add a console.log breadcrumb per layer and watch where the trace stops.
  • CORS errors in the browser: the preflight OPTIONS needs headers too — register CORS middleware before auth, or the preflight dies on a 401.
  • Auth runs but the route never does: you’re missing next() after successful verification.
  • Middleware silent on some routes: check the mount path — app.use('/api', mw) skips everything outside /api.
  • Handler throws but you get an HTML error page: the error never reached your middleware — it’s missing the err first parameter, and three-argument functions are skipped for errors.

Further Reading

Frequently Asked Questions

What does next() actually do in Express middleware?

It hands control to the next function in the chain — either the next registered middleware or the route handler. Skip it and the request stalls forever. Call it with an argument (next(err)) and Express jumps straight to the error-handling middleware, skipping everything between.

Can middleware modify the response body?

In Express, not cleanly — the body may already be streamed by the time middleware sees the response. You can wrap res.send/res.json, but it's fiddly. FastAPI and Spring make post-processing easier; either way, headers and status codes are the safe things to touch.

How many middleware layers are too many?

No hard limit, but every layer adds latency to every request. If your chain is past a dozen items, some of them probably belong at the route level instead. Profile before cutting — a logging middleware costs microseconds; a synchronous token verification on every request is the expensive one.

Should validation live in middleware or in the handler?

Either works; what matters is where the rule applies. Middleware is right when the same validation covers many routes ("every POST body under 1MB, valid JSON"). Per-route validation — "user_id must exist" — belongs in a dependency, guard or the handler itself.

Why isn't my Express error handler being called?

Two usual suspects: it's registered before the routes (it must come last), or it's missing the err first parameter — Express recognizes error middleware only by its four-argument signature.