NestJS – Middleware

August 9, 20268 min readUpdated 9/5/2026

Middleware is the outermost layer of a Nest application, and the only part of the pipeline that is not really Nest. It is an Express middleware function with a class around it: same (req, res, next) signature, same position, same abilities.

That makes it the least interesting stage to describe and the most interesting one to choose correctly, because the reason to reach for it is almost always positional.

NestMiddleware

@Injectable()
export class RequestIdMiddleware implements NestMiddleware {
  use(req: RequestWithId, res: Response, next: NextFunction): void {
    const id = incomingId(req) ?? randomUUID()
    req.requestId = id
    res.setHeader('x-request-id', id)
    next()
  }
}

One method. Call next() and the request continues; do not call it and the request stops there, which means you are responsible for answering it.

@Injectable() means it is a provider like everything else, so it can take dependencies through its constructor. That is the one thing this has over a plain Express function.

Registering it

Middleware is the only part of the pipeline with no APP_* token. There is no APP_MIDDLEWARE, because middleware is not resolved per request out of the container — it is handed to the underlying Express application at startup, against routes. So it is configured imperatively, by the module:

export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer): void {
    consumer.apply(RequestIdMiddleware).forRoutes('*')
  }
}

The module implements NestModule and Nest calls configure at startup. forRoutes takes a path, a controller class, or several:

// every route
consumer.apply(RequestIdMiddleware).forRoutes('*')

// one controller's routes
consumer.apply(SomeMiddleware).forRoutes(ProjectsController)

// a path and method, with exclusions
consumer
  .apply(SomeMiddleware)
  .exclude({ path: 'api/v1/auth/login', method: RequestMethod.POST })
  .forRoutes({ path: 'api/v1/*', method: RequestMethod.ALL })

Multiple middlewares in one apply run left to right. Several consumer.apply(...) calls run in the order they are written.

Why this job is middleware

Assigning a request id looks like something an interceptor could do. It cannot, and the reason is the whole point of this lesson.

Middleware runs before guards. An interceptor's work happens around the route handler, and a request rejected by JwtAuthGuard never reaches a handler — so an interceptor doing this job would stamp an id on every 200 and on no 401.

The requests you most want to correlate in a log are exactly the ones that failed. That makes "before the guards" not a detail but the requirement.

Here is the difference, on a route that requires authentication, called without a token:

$ curl -i http://localhost:3001/api/v1/auth/me
HTTP/1.1 401 Unauthorized
x-request-id: 63bed0e4-989f-4a5a-89a6-316c52ef6733

{"message":"Sign in to do that.","error":"Unauthorized","statusCode":401,
 "path":"/api/v1/auth/me","requestId":"9b2bbbc3-e21a-429a-b6bc-64d001a5c4b9"}

The guard threw. No interceptor ran. The header and the body still carry an id, because the middleware had already assigned one — and a user reporting "it said sign in again" is now holding the key to their own log line.

What it gives up in exchange

Middleware pays for that position. It gets the raw Express req and res and no ExecutionContext, so it cannot see which handler is about to run, cannot read route metadata with Reflector, and cannot see what the handler returned.

That is the trade, and it is the right way round for this job — this needs the response object, not the route. It is the wrong way round for logging an outcome, which is why the logging interceptor is an interceptor and knows the handler name, the status and the duration.

The two are complementary rather than alternatives, and this application uses both.

Trusting a header from a stranger

Accepting an inbound x-request-id is what makes a trace span a proxy and your API. It is also a string from a stranger about to be written to a log file:

function incomingId(req: Request): string | null {
  const header = req.headers['x-request-id']
  if (typeof header !== 'string') return null

  const trimmed = header.trim()
  if (trimmed.length === 0 || trimmed.length > 64) return null
  return /^[A-Za-z0-9._-]+$/.test(trimmed) ? trimmed : null
}

An unfiltered value can carry newlines, which forge whole log lines, or a few kilobytes of padding on every request. So the charset is restricted and the length is capped.

Note the shape of the failure: anything unacceptable is replaced with a fresh UUID rather than rejected. A malformed trace header is not worth failing a request over, and a middleware that 400s on a header the client did not know it was sending is a bad neighbour.

$ curl -i -H 'x-request-id: trace-abc-123' .../auth/me | grep -i x-request-id
x-request-id: trace-abc-123

$ curl -i -H 'x-request-id: bad id with spaces' .../auth/me | grep -i x-request-id
x-request-id: 0856db06-70a7-489d-8e9b-ff8d0cbf5b3d

The typeof header !== 'string' check is doing real work too — Express gives an array when a header arrives more than once, and two ids are no id.

Functional middleware

When there are no dependencies, a plain function works and Nest accepts it:

export function requestId(req: Request, res: Response, next: NextFunction) {
  res.setHeader('x-request-id', randomUUID())
  next()
}

// consumer.apply(requestId).forRoutes('*')

The class form is worth preferring by default, for the reason that applies to every other part of Nest: the day it needs configuration, a class can take ConfigService in its constructor and a function cannot without reaching for a global. Converting later is a small change, and doing it up front costs three lines.

Third-party Express middleware plugs straight in, and for application-wide concerns the conventional place is main.ts rather than configure:

app.use(helmet())
app.use(cookieParser())

Both are equivalent. app.use reads better for library middleware that applies to everything; configure is for middleware whose routing is part of the decision.

Middleware and the body

By the time your middleware runs, Nest has already installed body parsers, so req.body is usually parsed. That matters for the one job middleware genuinely owns here: webhook signature verification needs the raw bytes, and a parsed body cannot be un-parsed.

const app = await NestFactory.create(AppModule, { rawBody: true })

That option keeps the raw buffer on the request alongside the parsed body, which is what lets a handler recompute an HMAC over exactly the bytes that were signed. Reserialising the parsed object produces different bytes — a reordered key or a changed number format is enough — and the signature will not match.

The general shape of the problem is worth recognising: anything that must see a request before it is transformed has to run before the transformation, and middleware is the only stage outside all of them.

The one that is easy to get wrong

Middleware runs before guards, which means it runs for unauthenticated requests. Anything expensive there is available to anybody:

// The wrong way. This runs before JwtAuthGuard, so an unauthenticated flood costs a
// database query per request — and the guard that would have rejected them never runs.
async use(req: Request, res: Response, next: NextFunction) {
  const settings = await this.repo.findOne({ where: { host: req.hostname } })
  next()
}

Rate limiting is the case where running before the guards is the point — you want to shed load before doing work — and it is exactly the case where the middleware itself must be cheap. Keep it to in-memory or Redis operations, and put anything that needs a query behind the guards.

What runs before your middleware

Middleware is outermost among the things you write, and not outermost overall. Ahead of it sit Express's own layers: the body parsers Nest installs, the CORS handler if app.enableCors() was called, and anything registered with app.use during bootstrap.

That ordering explains a question people hit early: a CORS preflight OPTIONS request is answered before your middleware sees it, so a middleware that logs every request logs no preflights. It also explains why helmet() belongs in main.ts rather than in configure — security headers want to be on everything, including responses produced before routing.

Ordering between middlewares

Within one configure, a single apply with several classes runs them left to right, and separate apply calls run in the order written:

consumer.apply(RequestIdMiddleware, RequestLogMiddleware).forRoutes('*')
consumer.apply(TenantMiddleware).forRoutes('api/v1/*')

That is worth knowing when one middleware depends on another having run — a logger wanting the request id has to come after the middleware that assigns it, and there is no error if it does not, only a missing field.

Middleware configured in an imported module runs before middleware configured in the importer, which is rarely something to rely on. When order genuinely matters, put both in the same apply where it is visible.

Testing middleware

Middleware has the simplest test of anything in the pipeline, because its three arguments are plain objects:

const req = { headers: {} } as Request
const res = { setHeader: (k: string, v: string) => { headers[k] = v } } as unknown as Response
let called = false

middleware.use(req as RequestWithId, res, () => { called = true })

expect(called).toBe(true)

Asserting that next() was called is the one that earns its place. A middleware that forgets it does not error — the request simply hangs until the client times out, which in a browser looks like the server being slow and gives no hint where to look.

The other worthwhile cases are the header-validation branches: a well-formed inbound id is echoed, and a hostile one is replaced rather than repeated.

When to use it

Middleware is right when the job has to happen for every request including rejected ones, needs the raw request or response, and does not need to know anything about the route.

In practice that is a short list: request ids and correlation, security headers, CORS, body parsers and raw-body capture for webhook signatures, compression, and third-party integrations that were written as Express middleware.

Everything else is better served further in. Authentication is a guard, because a guard's answer is yes or no and it has an ExecutionContext to work with. Response shaping and outcome logging are interceptors. Input conversion is a pipe.

The test that settles most cases in one question: does this need to run even when a guard rejects the request? If yes, it is middleware. If no, something further in will do the job with better information.

Next: exception filters — the stage at the other end, which catches whatever anything else threw.