The request pipeline: middleware, guards, interceptors, pipes, filters
The request runs middleware → guard → interceptor(pre) → pipe → handler → interceptor(post) → filter. Two senior traps: a guard sees the raw un-validated body (pipes run after it), and a hand-newed global enhancer is outside DI — bind via APP_GUARD to inject.
The RolesGuard looked perfect in review. It read @Roles('admin') off the handler with Reflector, checked the user’s roles against the database through UsersService, and returned false for everyone else. The author wired it in main.ts the way the docs show for “apply it everywhere”: app.useGlobalGuards(new RolesGuard(reflector, usersService)). Except reflector and usersService weren’t in scope in main.ts, so someone passed new RolesGuard(new Reflector(), null) to make it compile. It shipped. For three weeks every authenticated user was an admin, because the guard’s usersService was null, the role check threw, and the surrounding error handling defaulted open. Nothing in the test suite caught it — the tests constructed the guard by hand too, with a stub service that always said yes. This lesson is about the seven-stage pipeline every request runs through, and the two places seniors get burned: what each enhancer can see, and how it gets its dependencies.
The seven stages, in order
Before you write the first line of a guard or interceptor, you need to know where in the pipeline it runs — because the stage determines what data is available, and putting logic at the wrong stage is exactly how the hook’s three-week security hole happened.
Every HTTP request that reaches a Nest handler runs a fixed gauntlet of enhancers. The order is not configurable, and getting it wrong in your head is how authorization logic ends up in the wrong place. Left to right:
middleware → guard → interceptor (pre) → pipe → handler → interceptor (post) → filter (on throw)
- Middleware runs first, at the Express/Fastify level, bound in a module’s
configure(consumer). It has rawreq,res,next— but it runs before Nest has resolved which handler will serve the route, so it has noExecutionContext, no handler reference, no route metadata. - Guards run next.
canActivate(ctx)returns a boolean (or a Promise/Observable of one);falseshort-circuits with a403. This is where authorization lives. A guard does get anExecutionContext, so it can read handler metadata viaReflector— but the request body has not been validated or transformed yet. - Interceptors (pre half) wrap the handler. Code before
next.handle()runs here. - Pipes run on the handler arguments —
@Body(),@Param(),@Query()— validating and transforming them (ValidationPipe,ParseIntPipe). They run after guards and after the interceptor’s pre-half, right before the handler. - The route handler executes.
- Interceptors (post half) — the RxJS pipe after
next.handle()(map,timeout, caching, response shaping) runs on the way out. One interceptor therefore gives you two execution points around the handler. - Exception filters catch anything thrown in any of the above, mapping the error to an HTTP response. A filter is the last line.
// A guard runs at stage 2 — BEFORE pipes. It sees handler metadata, not a validated DTO.
@Injectable()
export class RolesGuard implements CanActivate {
constructor(
private readonly reflector: Reflector, // injected — reads @Roles() metadata
private readonly users: UsersService, // injected — checks roles in the DB
) {}
async canActivate(ctx: ExecutionContext): Promise<boolean> {
const required = this.reflector.get<string[]>('roles', ctx.getHandler());
if (!required) return true; // no @Roles() → public
const { user } = ctx.switchToHttp().getRequest();
return this.users.hasAnyRole(user.id, required);
}
}// An interceptor is two execution points: before next.handle() and after, via RxJS.
@Injectable()
export class TimingInterceptor implements NestInterceptor {
intercept(ctx: ExecutionContext, next: CallHandler): Observable<unknown> {
const start = Date.now(); // PRE-handler half
return next.handle().pipe( // ← handler runs here
map((data) => ({ data, ms: Date.now() - start })), // POST-handler half
);
}
}Binding scope: where an enhancer applies
Each of the four enhancer kinds (guards, interceptors, pipes, filters) binds at one of four scopes: global, controller (a class-level @UseGuards()), method (@UseGuards() on the handler), and for pipes also parameter (@Body(new ValidationPipe())). Within a kind, execution runs global → controller → method, outermost first. Scope answers “where does this apply”; it does not change the stage ordering above.
The DI trap: new versus APP_GUARD
Here is the trap that bit the RolesGuard. There are two ways to register a global enhancer, and they are not equivalent:
// ANTI-PATTERN: you `new` it yourself, so it lives OUTSIDE the DI container.
// It cannot inject Reflector or UsersService — you'd have to hand-build them.
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalGuards(new RolesGuard(/* what do I pass here? */));
await app.listen(3000);
}
// CORRECT: register via the APP_GUARD token in a module. Nest instantiates it
// through DI, so Reflector and UsersService are injected normally.
@Module({
providers: [
UsersService,
{ provide: APP_GUARD, useClass: RolesGuard }, // global AND DI-aware
],
})
export class AppModule {}app.useGlobalGuards(new X()) is fine for a dependency-free enhancer. The moment the enhancer needs to inject anything — Reflector, ConfigService, a repository — you must register it through the provider token: APP_GUARD, APP_INTERCEPTOR, APP_PIPE, or APP_FILTER. Those make the enhancer a real provider that Nest constructs, so its constructor dependencies resolve from the container instead of being null.
▸Why this works
Why can’t a guard just read the validated DTO? Because pipes run at stage 4 and guards at stage 2 — the guard executes before any pipe has touched the arguments. At guard time, request.body is whatever the body parser produced: a plain object, not an instance of your DTO class, with numbers still strings and no class-validator checks applied. class-transformer hasn’t run, so body instanceof CreatePostDto is false. Any authorization that depends on validated or coerced input (e.g. “the owner field must match the JWT subject”) cannot trust the body inside a guard — it must validate the relevant field manually in the guard, or move that decision into the handler or an interceptor that runs after the pipe. Conflating the two stages is how an authZ check silently reads undefined.
Failure modes seniors hit
AuthZ in a pipe, or a guard expecting a validated body. Because guards run before pipes, a guard sees the raw body — a DTO class isn’t instantiated, numbers are still strings. Authorization that depends on validated input belongs in the handler or a post-pipe interceptor, not the guard, and never in a pipe (a pipe transforms data, it isn’t an authorization boundary).
Expecting an interceptor to map errors. An interceptor can catch via catchError in its RxJS pipe, but the canonical error-to-response mapping is the exception filter (@Catch, catch(exception, host)). Doing both causes double-handling. And scope matters for what a filter catches: a guard throws at stage 2, before any method-scoped filter is in play, so only a higher-scoped (global) filter catches an exception thrown inside a guard.
Middleware reaching for handler metadata. Middleware runs before route resolution, so there is no ExecutionContext and no way to read @Roles() or any handler decorator. If you need handler metadata, you need a guard — that is precisely why guards exist as a distinct stage.
Your RolesGuard needs to inject Reflector (to read @Roles() metadata) and UsersService (to check roles in the DB), and you want it applied to every route. How do you register it globally?
A guard's canActivate reads request.body and checks `body.ownerId === user.id`. The handler's @Body() is typed as a CreatePostDto with class-validator. What does the guard actually see in request.body?
Why does `app.useGlobalGuards(new RolesGuard())` fail to inject Reflector while `{ provide: APP_GUARD, useClass: RolesGuard }` injects it correctly?
- 01List the seven stages of the Nest request pipeline in order, and for each say what it can and cannot see.
- 02Explain the two senior traps: why a guard can't trust the body, and why a hand-newed global enhancer can't inject its dependencies.
Every HTTP request that reaches a Nest handler runs a fixed seven-stage gauntlet, and the order is not configurable: middleware → guard → interceptor(pre) → pipe → handler → interceptor(post) → filter(on throw). Middleware runs first at the Express/Fastify level with raw req/res/next but no ExecutionContext, because the route handler isn’t resolved yet. Guards run next — canActivate decides authorization and can read handler metadata via Reflector, but the body is still raw. The interceptor’s pre-half runs before next.handle(); then pipes validate and transform the handler arguments; then the handler executes; then the interceptor’s post-half runs its RxJS pipe on the way out (so one interceptor is two execution points); and an exception filter catches anything thrown anywhere and maps it to a response. Each enhancer binds at global, controller, or method scope (pipes also at parameter), executing global → controller → method, but scope never changes the stage order. Two traps burn seniors. First, because guards run before pipes, a guard sees the raw un-validated body — not a DTO instance, numbers still strings — so authZ that depends on validated input belongs in the handler or a post-pipe interceptor, never a guard reading a “validated” body and never a pipe. Second, binding a global enhancer two ways is not equivalent: app.useGlobalGuards(new RolesGuard()) lives outside the DI container and injects nothing (Reflector and services are null — the incident where every user became admin), whereas { provide: APP_GUARD, useClass: RolesGuard } (or APP_INTERCEPTOR/APP_PIPE/APP_FILTER) makes it a DI-aware provider whose Reflector and UsersService resolve from the container. useGlobalGuards is only safe for a dependency-free enhancer; anything with deps must bind via the APP_* token. Now when you see a guard that always passes, or an authorization hole after a refactor, ask two questions: where in the seven stages did the logic run, and was the enhancer constructed inside or outside the DI container?
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.