Request Lifecycle: How Web Frameworks Handle a Request · JavaScript Frameworks: Express, NestJS and Next.js

NestJS: Middleware, Guards, Interceptors, Pipes and Filters

Why mobile users got 403 errors from an ownership guard after a validation pipe was added, and the fixed order in which NestJS runs each part of its request pipeline.

Scenario

Example app: a NestJS banking API has an AccountOwnerGuard that checks request.body.accountId is one of the user's accounts. A global ValidationPipe({ transform: true }) converts DTO fields to the declared types (accountId: number).

What you see in production

  • Web users (sending JSON numbers) can transfer money; mobile users get 403 Forbidden. The mobile app sends IDs as strings ("42").
  • Inside the controller, dto.accountId is a number for both clients, so developers can't reproduce the 403 by reading the handler code.

The naive code

@Injectable()
export class AccountOwnerGuard implements CanActivate {
  canActivate(ctx: ExecutionContext): boolean {
    const req = ctx.switchToHttp().getRequest();
    return req.user.accountIds.includes(req.body.accountId);   // raw body: "42" !== 42
  }
}

Reproduce it

NestJS runs the parts of its pipeline in a fixed order, documented in its request lifecycle: middleware, then guards, then interceptors (before), then pipes, then the handler, then interceptors (after), with exception filters handling anything thrown. The simulation sends the same transfer from both clients.

Guards run before pipesTry it yourself
type Req = { body: Record<string, unknown>; user: { accountIds: number[] }; log: string[] };

const middleware = (req: Req) => { req.log.push("middleware"); };
const ownerGuard = (req: Req) => {
  req.log.push("guard");
  if (!req.user.accountIds.includes(req.body.accountId as number)) throw new Error("403 Forbidden");
};
const loggingInterceptorBefore = (req: Req) => { req.log.push("interceptor:before"); };
const validationPipe = (req: Req) => {
  req.log.push("pipe");
  return { accountId: Number(req.body.accountId), amount: Number(req.body.amount) };   // transform: true
};
const handler = (dto: { accountId: number; amount: number }) => `200 transferred ${dto.amount} from ${dto.accountId}`;

function run(req: Req): string {
  try {
    middleware(req);
    ownerGuard(req);                    // sees the RAW body
    loggingInterceptorBefore(req);
    const dto = validationPipe(req);    // types fixed only here
    const result = handler(dto);
    req.log.push("interceptor:after");
    return result;
  } catch (e) {
    req.log.push("exception filter");
    return (e as Error).message;
  }
}

for (const [client, accountId] of [["web", 42], ["mobile", "42"]] as const) {
  const req: Req = { body: { accountId, amount: "100" }, user: { accountIds: [42] }, log: [] };
  const result = run(req);
  console.log(`${client.padEnd(6)} -> ${result.padEnd(32)} [${req.log.join(" > ")}]`);
}

Output

web    -> 200 transferred 100 from 42      [middleware > guard > interceptor:before > pipe > interceptor:after]
mobile -> 403 Forbidden                    [middleware > guard > exception filter]

Root cause

Guards run before pipes. The guard read the raw request body, where the mobile client's accountId was still a string, so includes() compared "42" with 42 and failed. The validation pipe that would have converted it ran later.

The lifecycle, step by step

  1. Middleware (global, then module-bound): Express or Fastify middleware, for things like request IDs, CORS and body parsing.
  2. Guards (global → controller → route): decide whether the request may proceed (CanActivate); typically authentication and authorisation.
  3. Interceptors, before (global → controller → route): code around the handler, such as logging, timing, caching.
  4. Pipes (global → controller → route → parameter): validate and transform arguments (ValidationPipe, ParseIntPipe).
  5. Controller handler, then services.
  6. Interceptors, after (in reverse order): transform the response (RxJS map), handle timeouts.
  7. Exception filters (route → controller → global): turn anything thrown at any stage into a response.

See guards for their position in the pipeline.

The fix

  • Keep guards to identity and coarse permissions (is the user authenticated, do they have the role) using data that's already typed (the JWT payload set by the auth guard).
  • Do resource-level checks (does this account belong to the user) after validation: in the service, or with a pipe that loads and authorises the entity, using the transformed DTO.
  • If a guard must read the body, normalise and validate the value inside the guard itself.
  • Test guards with the same raw payloads real clients send (strings, missing fields), not with typed DTOs.

Trade-offs

  • Running guards early is deliberate: unauthenticated requests are rejected before any validation or business work.
  • Moving checks into services keeps them close to the data but can scatter authorisation; use a shared policy service to keep it consistent.

Detect it in production

  • Break down 403 responses by client type and app version; a spike for one client suggests input-format differences.
  • Log which pipeline stage produced an error (guard, pipe, handler, filter).

Key points

  • NestJS order: middleware → guards → interceptors (before) → pipes → handler → interceptors (after), with exception filters catching errors from any stage.
  • Guards see the raw request; pipes transform and validate it later.
  • Do resource-level authorisation after validation, with typed data.

Practice questions

In what order does NestJS run middleware, guards, interceptors, pipes and filters?

Answer

Middleware, then guards, then interceptors (before the handler), then pipes, then the route handler, then interceptors on the way out; exception filters handle exceptions thrown at any stage.

Why can't a NestJS guard rely on ValidationPipe having transformed the body?

Answer

Guards run before pipes, so a guard sees the raw request body. Use typed data the guard owns (the authenticated user), validate inside the guard, or move resource checks after validation.

When would you use an interceptor instead of middleware in NestJS?

Answer

When you need access to the execution context (which handler and class will run) and to the handler's result: response mapping, caching, timing, timeouts. Middleware runs earlier, knows only the raw request and response, and can't see the handler's return value.

Open in the app to mark lessons complete, take notes and track your progress.