Skip to main content
Node.js · lesson 20 of 20
Node.js advanced Lesson 20 of 20

NestJS Guards, Interceptors, and Exception Filters

Log a guard, pipe, interceptor, and filter running around one NestJS handler, then build a 403 guard, a timing interceptor, and an error-reshaping filter.

By SkillByExample · Published

nodejs nestjs typescript middleware

NestJS: Modules, Controllers, and Providers built UsersController, UsersService, and UsersModule, reused unchanged below; NestJS Validation with Zod and Standard Schema added a validation pipe to a different controller’s POST create route, not this one. This lesson adds the three pieces that wrap a route from the outside — a guard, an interceptor, and an exception filter — and logs the real order they run in, instead of assuming it from the docs. Every command runs against node:26.13 in Docker, NestJS 12.3.2, and TypeScript 6.0.3, the same pins the rest of this topic uses.

Guards, an Interceptor, and the Controller They Wrap

ApiKeyGuard is a CanActivate: it reads the incoming request straight off ExecutionContext and returns a boolean — no next() to call, unlike Express middleware. It’s a teaching example, not a complete auth system: the key is one static string shared by every caller, sent as a plain header with no hashing or rotation, and it only protects routes that are explicitly decorated with @UseGuards(ApiKeyGuard) — anything else on the server is wide open.

// src/api-key.guard.ts
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";

@Injectable()
export class ApiKeyGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    console.log("[lifecycle] guard");
    const req = context.switchToHttp().getRequest();
    return req.headers["x-api-key"] === "secret";
  }
}

TimingInterceptor is a NestInterceptor: intercept runs its own code, then has to call next.handle() itself to let the pipeline continue — the returned Observable is what the handler eventually produces, and tap adds a side effect without changing that value:

// src/timing.interceptor.ts
import {
  CallHandler,
  ExecutionContext,
  Injectable,
  NestInterceptor,
} from "@nestjs/common";
import { Observable, tap } from "rxjs";

@Injectable()
export class TimingInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    console.log("[lifecycle] interceptor before");
    const start = Date.now();
    return next.handle().pipe(
      tap(() => {
        const ms = Date.now() - start;
        console.log(`[lifecycle] interceptor after (${ms}ms)`);
      }),
    );
  }
}

ParseIntPipe from NestJS: Modules, Controllers, and Providers converts :id on its own, with no logging of any kind — LoggingParseIntPipe wraps it in one extra line so the pipe’s own position in the order is visible too, without changing what it actually validates:

// src/logging-parse-int.pipe.ts
import { ArgumentMetadata, Injectable, ParseIntPipe, PipeTransform } from "@nestjs/common";

@Injectable()
export class LoggingParseIntPipe implements PipeTransform {
  private readonly inner = new ParseIntPipe();

  transform(value: string, metadata: ArgumentMetadata) {
    console.log("[lifecycle] pipe");
    return this.inner.transform(value, metadata);
  }
}

UsersService.findOne keeps NestJS: Modules, Controllers, and Providers’s NotFoundException for a missing user, now behind a simulated delay (setTimeout(resolve, 50), below) so the interceptor above has something real to measure, plus one deliberate Error — not a real bug, just a stand-in for one, used later in this lesson to see what happens when nothing catches it:

// src/users.service.ts
import { Injectable, NotFoundException } from "@nestjs/common";

export interface User {
  id: number;
  name: string;
}

@Injectable()
export class UsersService {
  private readonly users: User[] = [
    { id: 1, name: "Ada" },
    { id: 2, name: "Linus" },
  ];

  async findOne(id: number): Promise<User> {
    await new Promise((resolve) => setTimeout(resolve, 50));
    if (id === 0) {
      throw new Error("boom");
    }
    const user = this.users.find((u) => u.id === id);
    if (!user) {
      throw new NotFoundException(`user ${id} not found`);
    }
    return user;
  }
}

UsersController wires both onto every route with @UseGuards/@UseInterceptors at the class level, and logs its own line so the handler’s position in the order is visible too:

// src/users.controller.ts
import { Controller, Get, Param, UseGuards, UseInterceptors } from "@nestjs/common";
import { UsersService } from "./users.service.js";
import { ApiKeyGuard } from "./api-key.guard.js";
import { TimingInterceptor } from "./timing.interceptor.js";
import { LoggingParseIntPipe } from "./logging-parse-int.pipe.js";

@Controller("users")
@UseGuards(ApiKeyGuard)
@UseInterceptors(TimingInterceptor)
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Get(":id")
  async findOne(@Param("id", LoggingParseIntPipe) id: number) {
    console.log("[lifecycle] handler");
    return this.usersService.findOne(id);
  }
}

UsersModule, AppModule, and main.ts are unchanged in shape from the earlier two lessons — no filter is registered yet:

// src/users.module.ts
import { Module } from "@nestjs/common";
import { UsersController } from "./users.controller.js";
import { UsersService } from "./users.service.js";

@Module({
  controllers: [UsersController],
  providers: [UsersService],
})
export class UsersModule {}
// src/app.module.ts
import { Module } from "@nestjs/common";
import { UsersModule } from "./users.module.js";

@Module({
  imports: [UsersModule],
})
export class AppModule {}
// src/main.ts
import "reflect-metadata";
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module.js";

const app = await NestFactory.create(AppModule);
await app.listen(3100);
console.log("listening on :3100");
{
  "name": "nestjs-guards-interceptors-filters",
  "version": "1.0.0",
  "type": "module",
  "dependencies": {
    "@nestjs/common": "12.3.2",
    "@nestjs/core": "12.3.2",
    "@nestjs/platform-express": "12.3.2",
    "reflect-metadata": "0.2.2",
    "rxjs": "7.8.2"
  },
  "devDependencies": {
    "typescript": "6.0.3"
  }
}
{
  "compilerOptions": {
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "target": "es2022",
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*.ts"]
}

node_modules lives in its own named Docker volume, nodejs-nestjs-guards-interceptors-filters-modules, for the same reason every lesson in this topic uses one — a bind mount shares files with the Linux VM too slowly for node_modules’ thousands of small files. This lesson also uses port 3100, not this topic’s usual 3000, because this host’s port 3000 is flooded with spurious GET / requests — docker logs during an idle period with zero curls sent still showed repeated NotFoundException: Cannot GET / lines; ports 3100 and 18080 were both confirmed clean over the same idle window:

$ docker run --rm -v "$(pwd)":/app -v nodejs-nestjs-guards-interceptors-filters-modules:/app/node_modules -w /app -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npm install --no-audit --no-fund

added 96 packages in 9s

The Real Execution Order

Building and starting the server once shows nothing about the order — that only shows up once a request actually arrives:

$ docker run --rm -v "$(pwd)":/app -v nodejs-nestjs-guards-interceptors-filters-modules:/app/node_modules -w /app -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx tsc
$ docker run -d --name nodejs-nest-guards -p 3100:3100 -v "$(pwd)":/app -v nodejs-nestjs-guards-interceptors-filters-modules:/app/node_modules -w /app node:26.13 node dist/main.js
2c54727d28a6d7a5085b5c46eb4764b4b0ac843826795187894f4183667a98f9
$ until docker logs nodejs-nest-guards 2>&1 | grep -q 'listening on'; do sleep 0.2; done
$ docker logs nodejs-nest-guards 2>&1 | sed 's/\x1b\[[0-9;]*m//g'
[Nest] 1  - 11/04/2026, 8:44:18 AM     LOG [NestFactory] Starting Nest application...
[Nest] 1  - 11/04/2026, 8:44:18 AM     LOG [InstanceLoader] AppModule dependencies initialized +11ms
[Nest] 1  - 11/04/2026, 8:44:18 AM     LOG [InstanceLoader] UsersModule dependencies initialized +0ms
[Nest] 1  - 11/04/2026, 8:44:18 AM     LOG [RoutesResolver] UsersController {/users}: +9ms
[Nest] 1  - 11/04/2026, 8:44:18 AM     LOG [RouterExplorer] Mapped {/users/:id, GET} route +6ms
[Nest] 1  - 11/04/2026, 8:44:18 AM     LOG [NestApplication] Nest application successfully started +2ms
listening on :3100

A real terminal shows those [Nest] lines in colour; the sed above strips the ANSI codes this block needs to stay plain text. A request carrying the right header is the first thing that actually exercises the guard, the interceptor, the pipe, and the handler together:

$ curl -si "http://localhost:3100/users/1" -H 'x-api-key: secret'
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 21
ETag: W/"15-Uz5tLeD/GUuik8TTq8kwMsgQWKc"
Date: Wed, 04 Nov 2026 08:44:20 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"id":1,"name":"Ada"}
$ docker logs nodejs-nest-guards 2>&1 | grep '\[lifecycle\]'
[lifecycle] guard
[lifecycle] interceptor before
[lifecycle] pipe
[lifecycle] handler
[lifecycle] interceptor after (52ms)

guard first, then interceptor before, then pipe (LoggingParseIntPipe converting "1" to 1 — the handler never runs with a string id), then handler, and only then interceptor after — confirming next.handle() really does wrap the rest of the chain rather than running alongside it. The elapsed time next to interceptor after is a real measurement, not a stand-in: UsersService.findOne’s own setTimeout delay is most of it, so the interceptor’s own overhead is in the single digits. The threshold below turns that specific number into a stable check that doesn’t care exactly how many milliseconds a given run took, only that it’s close to the delay setTimeout(resolve, 50) guarantees — repeated runs here land at 50-52ms, but the check’s own floor is 45, five milliseconds below the guarantee rather than above it, so it doesn’t flake if a run comes in a few milliseconds short of 50:

$ MS=$(docker logs nodejs-nest-guards 2>&1 | grep -oE 'interceptor after \([0-9]+ms\)' | tail -1 | grep -oE '[0-9]+'); [ "$MS" -ge 45 ] && echo SLOW
SLOW

A Guard Returning false Skips Everything After It

Dropping the x-api-key header entirely never reaches the pipe, the handler, or the interceptor’s own code — ApiKeyGuard.canActivate returns false, and NestJS turns that directly into a 403:

$ docker rm -f nodejs-nest-guards
nodejs-nest-guards
$ docker run -d --name nodejs-nest-guards -p 3100:3100 -v "$(pwd)":/app -v nodejs-nestjs-guards-interceptors-filters-modules:/app/node_modules -w /app node:26.13 node dist/main.js
26e82bf0146371223f0fec52cd52bc374e83e31a01cdec6ec350236d14e6f25f
$ until docker logs nodejs-nest-guards 2>&1 | grep -q 'listening on'; do sleep 0.2; done
$ curl -si "http://localhost:3100/users/1"
HTTP/1.1 403 Forbidden
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 69
ETag: W/"45-MZJWZc+Y+RUbHpnhz2B2Vipii24"
Date: Wed, 04 Nov 2026 08:44:26 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"message":"Forbidden resource","error":"Forbidden","statusCode":403}
$ docker logs nodejs-nest-guards 2>&1 | grep '\[lifecycle\]'
[lifecycle] guard

403, "Forbidden resource" — the exact message CanActivate returning false produces on its own, with no code in this project ever constructing a ForbiddenException by hand. The log confirms why: only guard printed. interceptor before, pipe, and handler never ran at all, because a guard rejecting a request happens before the framework builds any of the rest of that request’s pipeline — the cost of a rejected request is just the guard’s own check, nothing more.

Decorator Order Doesn’t Decide Execution Order

UsersController above declares @UseGuards before @UseInterceptors, in the same order they actually run — reversing the decorators tests whether that’s a coincidence:

// src/users.controller.ts (decorators reversed: interceptors declared before guards)
import { Controller, Get, Param, UseGuards, UseInterceptors } from "@nestjs/common";
import { UsersService } from "./users.service.js";
import { ApiKeyGuard } from "./api-key.guard.js";
import { TimingInterceptor } from "./timing.interceptor.js";
import { LoggingParseIntPipe } from "./logging-parse-int.pipe.js";

@Controller("users")
@UseInterceptors(TimingInterceptor)
@UseGuards(ApiKeyGuard)
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Get(":id")
  async findOne(@Param("id", LoggingParseIntPipe) id: number) {
    console.log("[lifecycle] handler");
    return this.usersService.findOne(id);
  }
}
$ docker rm -f nodejs-nest-guards
nodejs-nest-guards
$ docker run --rm -v "$(pwd)":/app -v nodejs-nestjs-guards-interceptors-filters-modules:/app/node_modules -w /app -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx tsc
$ docker run -d --name nodejs-nest-guards -p 3100:3100 -v "$(pwd)":/app -v nodejs-nestjs-guards-interceptors-filters-modules:/app/node_modules -w /app node:26.13 node dist/main.js
3ff487b3d8b8b5b9662ced3bafb0645a0c91faa62a13d5e9424983cbbe9c616c
$ until docker logs nodejs-nest-guards 2>&1 | grep -q 'listening on'; do sleep 0.2; done
$ curl -s -o /dev/null -w 'STATUS:%{http_code}\n' "http://localhost:3100/users/1"
STATUS:403
$ docker logs nodejs-nest-guards 2>&1 | grep '\[lifecycle\]'
[lifecycle] guard

Still 403, still only guard in the log — reversing the two decorators changed nothing about what ran, because NestJS resolves every @UseGuards/@UseInterceptors into its own fixed internal list per request type (guards, then interceptors, then the rest) rather than replaying the decorators in source order. The rest of this lesson reverts to the original order.

// src/users.controller.ts (reverted to the original order)
import { Controller, Get, Param, UseGuards, UseInterceptors } from "@nestjs/common";
import { UsersService } from "./users.service.js";
import { ApiKeyGuard } from "./api-key.guard.js";
import { TimingInterceptor } from "./timing.interceptor.js";
import { LoggingParseIntPipe } from "./logging-parse-int.pipe.js";

@Controller("users")
@UseGuards(ApiKeyGuard)
@UseInterceptors(TimingInterceptor)
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Get(":id")
  async findOne(@Param("id", LoggingParseIntPipe) id: number) {
    console.log("[lifecycle] handler");
    return this.usersService.findOne(id);
  }
}

An Unhandled Error Never Reaches the Interceptor’s “After” Log

GET /users/0 reaches UsersService.findOne, which throws a plain Error instead of returning or throwing an HttpException — no exception filter is registered yet, so NestJS’s own built-in one handles it:

$ docker rm -f nodejs-nest-guards
nodejs-nest-guards
$ docker run --rm -v "$(pwd)":/app -v nodejs-nestjs-guards-interceptors-filters-modules:/app/node_modules -w /app -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx tsc
$ docker run -d --name nodejs-nest-guards -p 3100:3100 -v "$(pwd)":/app -v nodejs-nestjs-guards-interceptors-filters-modules:/app/node_modules -w /app node:26.13 node dist/main.js
1dc2efeff9af55cd031cd4280019187d6f8842c29eddf0d246dc33b23fceac22
$ until docker logs nodejs-nest-guards 2>&1 | grep -q 'listening on'; do sleep 0.2; done
$ curl -si "http://localhost:3100/users/0" -H 'x-api-key: secret'
HTTP/1.1 500 Internal Server Error
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 52
ETag: W/"34-rlKccw1E+/fV8niQk4oFitDfPro"
Date: Wed, 04 Nov 2026 08:44:46 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"statusCode":500,"message":"Internal server error"}
$ docker logs nodejs-nest-guards 2>&1 | grep '\[lifecycle\]'
[lifecycle] guard
[lifecycle] interceptor before
[lifecycle] pipe
[lifecycle] handler

guard, interceptor before, pipe, handler — the same four steps the successful request showed — but interceptor after never printed. next.handle() returns an Observable, and tap’s callback only runs on that Observable’s success channel; a thrown Error turns into the error channel instead, which this interceptor’s tap never subscribes to. The exception skips straight past the rest of the interceptor to NestJS’s own default exception handling, which is exactly why the response body gives no hint of "boom" at all.

Reshaping the Error with an Exception Filter

ApiErrorFilter is an ExceptionFilter: it reads the exception, decides a status and a body, and writes the response itself through the raw platform object ArgumentsHost exposes — an HttpException (like the NotFoundException UsersService already throws) keeps its own status and message, anything else becomes a generic 500 with no details from the real error leaked:

// src/api-error.filter.ts
import {
  ArgumentsHost,
  Catch,
  ExceptionFilter,
  HttpException,
  HttpStatus,
} from "@nestjs/common";

@Catch()
export class ApiErrorFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    console.log("[lifecycle] filter");
    const res = host.switchToHttp().getResponse();
    const status =
      exception instanceof HttpException
        ? exception.getStatus()
        : HttpStatus.INTERNAL_SERVER_ERROR;
    const detail =
      exception instanceof HttpException
        ? exception.getResponse()
        : "unexpected error";
    res.status(status).json({ ok: false, status, detail });
  }
}

main.ts registers it with useGlobalFilters — nothing about UsersController, UsersService, the guard, or the interceptor changes:

// src/main.ts (registers the exception filter globally)
import "reflect-metadata";
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module.js";
import { ApiErrorFilter } from "./api-error.filter.js";

const app = await NestFactory.create(AppModule);
app.useGlobalFilters(new ApiErrorFilter());
await app.listen(3100);
console.log("listening on :3100");

The same GET /users/0 now gets a reshaped body, and the log gains the one line the previous section never printed:

$ docker rm -f nodejs-nest-guards
nodejs-nest-guards
$ docker run --rm -v "$(pwd)":/app -v nodejs-nestjs-guards-interceptors-filters-modules:/app/node_modules -w /app -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx tsc
$ docker run -d --name nodejs-nest-guards -p 3100:3100 -v "$(pwd)":/app -v nodejs-nestjs-guards-interceptors-filters-modules:/app/node_modules -w /app node:26.13 node dist/main.js
df430326fb3910e0a5592a8559299af32b5782f39dc64c9335492f56d5b6affc
$ until docker logs nodejs-nest-guards 2>&1 | grep -q 'listening on'; do sleep 0.2; done
$ curl -si "http://localhost:3100/users/0" -H 'x-api-key: secret'
HTTP/1.1 500 Internal Server Error
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 53
ETag: W/"35-oKhgn+3OUIkAOrH4mPD6onJW5Sc"
Date: Wed, 04 Nov 2026 08:44:54 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"ok":false,"status":500,"detail":"unexpected error"}
$ docker logs nodejs-nest-guards 2>&1 | grep '\[lifecycle\]'
[lifecycle] guard
[lifecycle] interceptor before
[lifecycle] pipe
[lifecycle] handler
[lifecycle] filter

"detail":"unexpected error" — the plain Error("boom") from UsersService never reaches the client in any form. ApiErrorFilter doesn’t treat every exception that way, though: a NotFoundException (the same one UsersService already throws, carried over from NestJS: Modules, Controllers, and Providers) keeps its own status and message intact inside detail, because instanceof HttpException is true for it — only a non-HttpException, like this plain Error, has its real message discarded. filter is the last line in the log — it runs after everything else in this request’s chain, never before. The next request shows that HttpException path for real:

$ curl -si "http://localhost:3100/users/9" -H 'x-api-key: secret'
HTTP/1.1 404 Not Found
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 102
ETag: W/"66-56sH7tl7AGE7MNhcDZjvVEs8/NQ"
Date: Wed, 04 Nov 2026 08:44:56 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"ok":false,"status":404,"detail":{"message":"user 9 not found","error":"Not Found","statusCode":404}}
$ docker rm -f nodejs-nest-guards
nodejs-nest-guards

"status":404 and detail.message is NotFoundException’s own string, "user 9 not found", reaching the client unchanged — the filter’s ok/status/detail wrapper is consistent across both error shapes, but it only ever wraps an HttpException’s real status and message, never replaces them the way it replaced the plain Error’s.

Practice

1. Add an UnauthorizedException (401) path to ApiKeyGuard: throw it directly when the header is present but doesn’t equal "secret", keeping the plain false return (403) only for a missing header entirely.

Solution
// src/api-key.guard.ts (401 for a wrong key, 403 for a missing one)
import {
  CanActivate,
  ExecutionContext,
  Injectable,
  UnauthorizedException,
} from "@nestjs/common";

@Injectable()
export class ApiKeyGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    console.log("[lifecycle] guard");
    const req = context.switchToHttp().getRequest();
    const key = req.headers["x-api-key"];
    if (key === undefined) {
      return false;
    }
    if (key !== "secret") {
      throw new UnauthorizedException("wrong api key");
    }
    return true;
  }
}
$ docker run --rm -v "$(pwd)":/app -v nodejs-nestjs-guards-interceptors-filters-modules:/app/node_modules -w /app -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx tsc
$ docker run -d --name nodejs-nest-practice1 -p 3100:3100 -v "$(pwd)":/app -v nodejs-nestjs-guards-interceptors-filters-modules:/app/node_modules -w /app node:26.13 node dist/main.js
795c9c420208ef7d1ccc6c871834ae3a420aaa883fa17aa22ec760c80fdaeee2
$ until docker logs nodejs-nest-practice1 2>&1 | grep -q 'listening on'; do sleep 0.2; done
$ curl -s -o /dev/null -w 'STATUS:%{http_code}\n' "http://localhost:3100/users/1"
STATUS:403
$ curl -s -o /dev/null -w 'STATUS:%{http_code}\n' "http://localhost:3100/users/1" -H 'x-api-key: wrong'
STATUS:401
$ docker rm -f nodejs-nest-practice1
nodejs-nest-practice1

No header at all still returns the plain-false 403; a header that’s present but wrong now gets the 401 thrown explicitly — canActivate can mix both styles in the same guard, since returning false and throwing are the only two outcomes NestJS ever reads from it.

2. Change TimingInterceptor to log a request’s HTTP method and URL (from context.switchToHttp().getRequest(), the same call the guard already uses) alongside the elapsed time.

Solution
// src/timing.interceptor.ts (logs method and URL too)
import {
  CallHandler,
  ExecutionContext,
  Injectable,
  NestInterceptor,
} from "@nestjs/common";
import { Observable, tap } from "rxjs";

@Injectable()
export class TimingInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    console.log("[lifecycle] interceptor before");
    const req = context.switchToHttp().getRequest();
    const start = Date.now();
    return next.handle().pipe(
      tap(() => {
        const ms = Date.now() - start;
        console.log(`[lifecycle] interceptor after ${req.method} ${req.url} (${ms}ms)`);
      }),
    );
  }
}
$ docker run --rm -v "$(pwd)":/app -v nodejs-nestjs-guards-interceptors-filters-modules:/app/node_modules -w /app -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx tsc
$ docker run -d --name nodejs-nest-practice2 -p 3100:3100 -v "$(pwd)":/app -v nodejs-nestjs-guards-interceptors-filters-modules:/app/node_modules -w /app node:26.13 node dist/main.js
81ac2eef442ba4898ca98355e38c1cd9798aeda7dbdfab44b811cb058bea79f8
$ until docker logs nodejs-nest-practice2 2>&1 | grep -q 'listening on'; do sleep 0.2; done
$ curl -s -o /dev/null "http://localhost:3100/users/1" -H 'x-api-key: secret'
$ docker logs nodejs-nest-practice2 2>&1 | grep 'interceptor after'
[lifecycle] interceptor after GET /users/1 (52ms)
$ docker rm -f nodejs-nest-practice2
nodejs-nest-practice2

GET /users/1 comes straight off the same req object the guard already reads — nothing about reading the request a second time, from a different piece of the pipeline, needs new wiring.

3. Add a second route, @Get() with no :id, returning a static array, and confirm the guard and interceptor from the controller-level decorators still wrap it — without adding a second @UseGuards/@UseInterceptors anywhere.

Solution
// src/users.controller.ts (adds a second route, same controller-level guard/interceptor)
import { Controller, Get, Param, UseGuards, UseInterceptors } from "@nestjs/common";
import { UsersService } from "./users.service.js";
import { ApiKeyGuard } from "./api-key.guard.js";
import { TimingInterceptor } from "./timing.interceptor.js";
import { LoggingParseIntPipe } from "./logging-parse-int.pipe.js";

@Controller("users")
@UseGuards(ApiKeyGuard)
@UseInterceptors(TimingInterceptor)
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Get()
  findAll() {
    console.log("[lifecycle] handler");
    return [{ id: 1, name: "Ada" }, { id: 2, name: "Linus" }];
  }

  @Get(":id")
  async findOne(@Param("id", LoggingParseIntPipe) id: number) {
    console.log("[lifecycle] handler");
    return this.usersService.findOne(id);
  }
}
$ docker run --rm -v "$(pwd)":/app -v nodejs-nestjs-guards-interceptors-filters-modules:/app/node_modules -w /app -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx tsc
$ docker run -d --name nodejs-nest-practice3 -p 3100:3100 -v "$(pwd)":/app -v nodejs-nestjs-guards-interceptors-filters-modules:/app/node_modules -w /app node:26.13 node dist/main.js
c208bca72ec0fd6883a0cb9983cb89573797482a541203b5e1f6b861dd9ef6b3
$ until docker logs nodejs-nest-practice3 2>&1 | grep -q 'listening on'; do sleep 0.2; done
$ curl -s -o /dev/null -w 'STATUS:%{http_code}\n' "http://localhost:3100/users"
STATUS:403
$ curl -s "http://localhost:3100/users" -H 'x-api-key: secret'
[{"id":1,"name":"Ada"},{"id":2,"name":"Linus"}]
$ docker logs nodejs-nest-practice3 2>&1 | grep '\[lifecycle\]'
[lifecycle] guard
[lifecycle] filter
[lifecycle] guard
[lifecycle] interceptor before
[lifecycle] handler
[lifecycle] interceptor after GET /users (1ms)
$ docker rm -f nodejs-nest-practice3
nodejs-nest-practice3

The unauthenticated request to GET /users still gets 403 from the same guard, and the authenticated one still gets timed — a controller-level @UseGuards/@UseInterceptors applies to every route the controller declares, not just the one it was written next to. The [lifecycle] filter line between the two requests is ApiErrorFilter, still registered globally from the earlier section: it reshapes the first request’s rejected ForbiddenException — the one NestJS’s routing layer throws on the guard’s behalf — into its {ok,status,detail} body the same way it reshapes any other exception, even one that fires before the interceptor or handler ever run.

4. Scope TimingInterceptor to only the findOne method (@UseInterceptors on that one handler instead of the controller class) and confirm findAll no longer logs interceptor before/interceptor after at all, while still being protected by the controller-level guard.

Solution
// src/users.controller.ts (interceptor scoped to findOne only)
import { Controller, Get, Param, UseGuards, UseInterceptors } from "@nestjs/common";
import { UsersService } from "./users.service.js";
import { ApiKeyGuard } from "./api-key.guard.js";
import { TimingInterceptor } from "./timing.interceptor.js";
import { LoggingParseIntPipe } from "./logging-parse-int.pipe.js";

@Controller("users")
@UseGuards(ApiKeyGuard)
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Get()
  findAll() {
    console.log("[lifecycle] handler");
    return [{ id: 1, name: "Ada" }, { id: 2, name: "Linus" }];
  }

  @UseInterceptors(TimingInterceptor)
  @Get(":id")
  async findOne(@Param("id", LoggingParseIntPipe) id: number) {
    console.log("[lifecycle] handler");
    return this.usersService.findOne(id);
  }
}
$ docker run --rm -v "$(pwd)":/app -v nodejs-nestjs-guards-interceptors-filters-modules:/app/node_modules -w /app -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx tsc
$ docker run -d --name nodejs-nest-practice4 -p 3100:3100 -v "$(pwd)":/app -v nodejs-nestjs-guards-interceptors-filters-modules:/app/node_modules -w /app node:26.13 node dist/main.js
f165c6708a320d5fc1fc288e9f0ee6164ebbccc6e67c9dce93a35c53f7f94e36
$ until docker logs nodejs-nest-practice4 2>&1 | grep -q 'listening on'; do sleep 0.2; done
$ curl -s -o /dev/null "http://localhost:3100/users" -H 'x-api-key: secret'
$ docker logs nodejs-nest-practice4 2>&1 | grep '\[lifecycle\]'
[lifecycle] guard
[lifecycle] handler
$ docker rm -f nodejs-nest-practice4
nodejs-nest-practice4

Just guard and handler — findAll still goes through the controller-level guard, but with no interceptor decorating that specific method, TimingInterceptor never runs for it at all; moving @UseInterceptors from the class down to one method narrows its scope to exactly that method.

Frequently Asked Questions

What order do a NestJS guard, pipe, interceptor, and exception filter run in?
A guard runs first and can reject the request before anything else executes. If it allows the request, every interceptor wrapping the route runs its code before next.handle() next, then every pipe on the route's parameters runs, then the handler itself. If the handler returns normally, each interceptor's after code runs in reverse order as the response passes back out. If anything in that chain throws — the guard, a pipe, the handler, or an interceptor — the nearest exception filter catches it instead, and any interceptor "after" code downstream of the throw never runs.
Why does returning false from a guard produce a 403, not a 401?
A CanActivate guard's canActivate() method is only allowed to return a boolean (or a Promise/Observable of one) or throw. When it returns false, NestJS's own routing layer throws a ForbiddenException on the guard's behalf, which is a 403 Forbidden with the message "Forbidden resource" — there's no way to return false and get a 401 Unauthorized instead. A guard that needs a different status code has to throw that exception itself (an UnauthorizedException, say) rather than returning false.
Why doesn't an interceptor's "after" logging run when the handler throws?
An interceptor's next.handle() returns an RxJS Observable, and the operators chained onto it — tap(), map(), and so on — only run their success callback when that Observable emits a value, which only happens when the handler returns normally. A thrown error turns into the Observable's error channel instead, which tap()'s single success callback never sees; only an operator that explicitly handles the error channel (catchError, or tap's own second argument) runs when the handler throws.
Does an exception filter run before or after NestJS sends the response?
Before. An exception filter's catch() method is itself responsible for writing the response — it receives the raw platform response object through ArgumentsHost and calls res.status(...).json(...) (or the equivalent) itself. A filter that throws its own error doesn't hang the request: on platform-express, that throw propagates out past Nest's exception handling to Express's own default error handler, which still completes the response with a real 500 and an HTML stack-trace page, confirmed by making a filter's catch() throw directly. The request only actually hangs when catch() returns normally without ever calling a response method at all (no res.json(), res.send(), or res.status().end()) — confirmed separately with a filter that does nothing but log and return, which left curl waiting past a 5-second timeout with no response at all.
Can a guard, interceptor, or filter be scoped to one route instead of a whole controller?
Yes. @UseGuards, @UseInterceptors, and @UseFilters all accept being placed on an individual route handler method instead of the controller class — this lesson puts them on the controller only to keep every route inside it protected and timed, but a single @Get() method decorated the same way scopes the same guard, interceptor, or filter to just that one route.