Node.js · lesson 20 of 20
Beginner
6Intermediate
10- 7 Structured Logging with Pino
- 8 Async Route Handlers and Errors in Express 5
- 9 Testing an HTTP API with node:test and fetch
- 10 The Same API in Fastify
- 11 JWT Auth Middleware
- 12 Graceful Shutdown and Health Checks
- 13 File Uploads with multer
- 14 A WebSocket Server with ws
- 15 OpenAPI Docs from Fastify Schemas
- 16 PostgreSQL with pg: Pools and Transactions
Advanced
4Also part of Node.js
10- + Files, Streams, and the Command Line in Node.js from JavaScript
- + JavaScript Event Loop Explained from JavaScript
- + JavaScript Modules: ESM and CommonJS from JavaScript
- + Introduction to Docker from Docker
- + Managing Docker Containers from Docker
- + Multi-Stage Builds from Docker
- + Docker Compose from Docker
- + Node.js with TypeScript from TypeScript
- + Unsafe File Uploads: secure_filename, Allowlists and Size Limits from Web Security (OWASP Top 10, fix-side)
- + Decorators in TypeScript from TypeScript
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
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.