NestJS Guards: CanActivate, ExecutionContext & Reflector
A complete guide to NestJS guards: how CanActivate, ExecutionContext, and the Reflector decide who reaches your route handler, with a cheat sheet.

- 1NestJS Request Lifecycle Explained (with Cheat Sheet)13 min
- 2NestJS Dependency Injection Explained (with Cheat Sheet)14 min
- 3NestJS Module Encapsulation Explained (with Cheat Sheet)14 min
- 4NestJS Testing Module: Provider Overrides (with Cheat Sheet)13 min
- 5NestJS Guards: CanActivate, ExecutionContext & Reflectoryou are here
A code review flags a "protected" admin endpoint. The controller has @UseGuards(RolesGuard) on the class, RolesGuard injects a UserService to check role hierarchy, and it's registered application-wide with app.useGlobalGuards(new RolesGuard(reflector, userService)) in main.ts. It looks careful. It is also broken: userService is undefined inside the guard, on every single request, and no test would have caught it because the app never crashed — it just let every request through the branch that assumes a user has no elevated role.
Nothing here is a typo. The guard class is correct. The decorator is correct. The one thing wrong is how the guard was registered — and until you know what a guard actually is to Nest's container, that line looks completely reasonable.
This is written against NestJS 12.0.x (verified September 2026, current @nestjs/core release, v12.0.4). The CanActivate interface, ExecutionContext, and Reflector covered here have been stable since well before v9 and are untouched by v12's ESM and Standard Schema changes — nothing in this article is version-fragile.
By the end of this article you'll be able to:
- Explain what a guard actually is — a DI-instantiated class, not a function you happen to call
canActivate - Read and use
ExecutionContextto get at the request, the handler, and the controller class from inside a guard - Build a
Reflector-backed@Roles()decorator and understand exactly why the method's metadata overrides the class's - Register a global guard the right way (
APP_GUARD) so it keeps its dependency injection - Predict, for any combination of global/controller/method guards, the exact order they run in and what "all must pass" means
You've written a NestJS controller, used @UseGuards() at least once, and you know what @Injectable() does. If you haven't read the NestJS request lifecycle episode of this series, it's a useful map of where guards sit relative to middleware, interceptors, and pipes — but this article is self-contained.
- The problem: a guard that quietly can't do its job
- The mental model: a guard is a provider, not a function
- Stage 1: the smallest correct guard
- Stage 2: ExecutionContext, properly
- Stage 3: Reflector and a real
@Roles()decorator - Stage 4: registering a global guard without losing DI
- Stage 5: composing guards — order and "all must pass"
- Edge cases and gotchas
- Best practices
- FAQ
- Cheat sheet
- Key takeaways
Here's the guard from the intro, in full:
And here's how it was wired up, because "global" sounded like the right word for "runs on every route":
app.useGlobalGuards() takes an instance. Nothing about that instance goes through Nest's dependency injection — you constructed it yourself, with new, outside any module, before the application's providers even exist to hand it a real UserService. Nest will happily call canActivate() on this object for every request. It just can't give it the dependency the guard was written to use.
The fix isn't a different guard. It's a different registration, covered in Stage 4. But to see why that fix works, you need the actual mental model of what a guard is.
The mental model: a NestJS guard is an ordinary DI provider — the same kind of class as a service — that additionally implements one method, canActivate(context: ExecutionContext), which Nest calls immediately before it would otherwise invoke your route handler. If canActivate resolves to true, the request keeps moving toward interceptors, pipes, and the handler. If it resolves to false, or throws, the handler never runs and Nest hands the request straight to the exception-filter layer.
Three consequences fall out of that one sentence, and they're the three things the intro's bug got wrong:
- A guard only gets real dependency injection if Nest constructs it.
@UseGuards(RolesGuard)(passing the class) lets Nest instantiate it through the container, resolving its constructor arguments normally.new RolesGuard(...)(passing an instance you built) does not — you're on your own for every dependency. - A guard doesn't see "a request" in the abstract — it sees an
ExecutionContext. That's a wrapper Nest builds fresh for every incoming call, giving the guard a uniform way to reach the underlying request and to ask "what handler and class is Nest about to invoke?" — which is exactly what a guard needs to look up route-specific metadata. - A guard's decision is binary, not additive. It either lets a request through or it doesn't. Anything more nuanced than "yes/no" — attaching data, transforming the body — is an interceptor's or a pipe's job, not a guard's.
The simplest guard that compiles and does something real:
Apply it to one route:
Key concept: canActivate can return boolean, Promise<boolean>, or Observable<boolean> — Nest awaits or subscribes to whichever you give it. A guard that calls a database or an external identity provider to check a session is completely normal; just make it async canActivate(...): Promise<boolean> and Nest will wait for it before deciding.
ExecutionContext is the single argument every guard, interceptor, and exception filter receives, and it answers two different questions:
"What kind of call is this, and what's the underlying request object?" — via switchToHttp(), switchToRpc(), or switchToWs(). Most guards only ever call context.switchToHttp().getRequest(), but the same guard class can run in front of a WebSocket gateway or a microservice handler if you check context.getType() first and branch — that's what makes ExecutionContext a context, not just an HTTP request wrapper.
"Which handler and which class is Nest about to call?" — via context.getHandler() (the specific route method, as a function reference) and context.getClass() (the controller class). This half is what makes metadata-driven guards possible, because it's the only way a guard can ask "does this specific route carry a @Roles(...) decorator?" — the guard runs once per registration, but getHandler()/getClass() tell it which route it's currently deciding for.
Key concept: getHandler() and getClass() return the raw function/class references, not strings — they exist so you can hand them to Reflector, which looks up metadata attached to those exact references. That's the bridge to Stage 3.
Hardcoding a role check per route doesn't scale, and neither does branching on handler.name — a rename breaks it silently. The idiomatic pattern is a custom decorator that attaches metadata, and a Reflector that reads it back inside a guard.
Key concept: getAllAndOverride(key, [handler, class]) checks the handler first, then the class, and returns the first one it finds — it does not merge arrays. That order in the array is why a method-level @Roles("admin") completely replaces the class-level @Roles("editor") rather than requiring both. If you actually want both handler and class metadata combined, Reflector also has getAllAndMerge(), which concatenates arrays instead of short-circuiting — reach for it explicitly when "either level can add a role" is the behavior you want.
Back to the intro's bug. app.useGlobalGuards(new RolesGuard(...)) builds the guard outside the container, so any constructor dependency has to be supplied by hand — which is exactly what went wrong. The fix is to register the guard as a provider, using the APP_GUARD injection token from @nestjs/core:
Because RolesGuard is now a normal provider, Nest resolves its constructor the usual way — Reflector (and, in the intro's case, a real UserService) get injected correctly, module-scoped providers work, and Test.createTestingModule can overrideProvider(RolesGuard) in tests the same way it overrides any other dependency, as covered in this series' testing module episode. None of that is available to a guard built with new in main.ts.
Key concept: app.useGlobalGuards() still exists and still works for a guard with zero dependencies — it's not deprecated. The rule is narrower and easy to remember: the moment a guard's constructor needs anything Nest would normally inject, register it through APP_GUARD, not useGlobalGuards().
A single request can pass through guards registered at three different scopes at once: global (APP_GUARD, or useGlobalGuards()), controller (@UseGuards() on the class), and method (@UseGuards() on the handler). Nest runs them in that exact order — global, then controller, then method — and within one @UseGuards(A, B) call, in the order listed.
This composition is a logical AND, not a fallback chain: every guard in the sequence must return (or resolve to) true, or the request is rejected at the first one that doesn't. There's no "guard B can override guard A's denial" — a single false anywhere in the chain ends the request immediately, and every guard after it, plus every interceptor and pipe, is skipped entirely.
If a global AuthGuard is also registered via APP_GUARD, the real order for any route on this controller is: AuthGuard → ThrottleGuard → RolesGuard → (method-level guards, if any) → interceptors → pipes → the handler.
- A guard that throws vs. a guard that returns
false. Returningfalseproduces a generic403 Forbidden. Throwing a specific exception —throw new UnauthorizedException("Session expired")— gives the client (and your logs) a far more useful signal, and is the idiomatic choice for anything beyond "just deny it." Reflectorneeds the exact same metadata key everywhere.SetMetadata(ROLES_KEY, ...)andreflector.getAllAndOverride(ROLES_KEY, ...)must use the identical string (or, better, the same exported constant). A typo in one spot means the guard silently seesundefinedand treats the route as unrestricted — this fails open, which is the worst direction for an auth check to fail.getAllAndOverridevsgetAllAndMerge. Covered in Stage 3, but worth repeating because it's the single most commonReflectormistake: reach forgetAllAndOverridewhen a method should be able to fully replace a class default, andgetAllAndMergewhen both levels should contribute.- WebSocket and microservice guards need a type check.
context.switchToHttp()throws if the current call isn't actually HTTP. A guard meant to run across transports should branch oncontext.getType()("http","ws","rpc") before picking whichswitchTo*()to call. - A denied guard skips the "before" half of interceptors too. Interceptors run after guards, so a rejected request never reaches even the setup code in an interceptor — only the exception-filter layer sees it.
- Keep guards to yes/no authorization decisions. If you find yourself mutating the request object inside a guard, that logic usually belongs in middleware (before routing) or an interceptor (after the handler is chosen) instead.
- Prefer metadata-driven guards over hardcoded checks. A
@Roles()/Reflectorpair scales to new routes with zero changes to the guard itself; anif (handler.name === "remove")branch doesn't. - Register anything with a dependency via
APP_GUARD, nevernew Guard(). It's the difference between a guard that's testable and overridable, and one that silently can't be either. - Fail closed, not open. If a metadata lookup comes back
undefinedbecause of a wiring mistake, decide what that should mean deliberately (usually: deny), rather than letting!requiredaccidentally mean "allow everyone." - One guard, one concern. A
ThrottleGuardand aRolesGuardcomposed via@UseGuards(ThrottleGuard, RolesGuard)are each easier to test and reuse than one guard doing both jobs.
After. Middleware runs first and doesn't know which controller or handler will end up serving the request; guards run once routing has resolved to a specific handler, which is what lets a guard call context.getHandler().
Yes, as long as Nest constructs the guard — via @UseGuards(SomeGuard) (the class) or an APP_GUARD provider. A guard instance you build yourself with new gets none of Nest's dependency injection.
Yes — @UseGuards(A, B, C) runs them in that order, and you can also stack a class-level @UseGuards() with a method-level one; both apply, in the global → controller → method order described in Stage 5.
A 403 Forbidden by default, handled by Nest's built-in exception layer. Throw a specific HttpException subclass from inside the guard if you need a different status code or a custom error body.
No — interceptors and custom decorators use it too, for the same reason: reading metadata attached to a handler or class via SetMetadata. Guards are just its most common consumer, because "does this route require X" is the canonical authorization question.
| Scope | Registration | Runs when | Gets DI? |
|---|---|---|---|
| Method | @UseGuards(G) on a handler | Only that route | Yes |
| Controller | @UseGuards(G) on a class | Every route in that controller | Yes |
| Global (correct) | APP_GUARD provider | Every route in the app | Yes |
| Global (limited) | app.useGlobalGuards(new G()) | Every route in the app | No |
Execution order for one request: global → controller → method, each one an AND — the first false (or thrown exception) stops the chain immediately.
- A guard is a DI provider with a
canActivate(context: ExecutionContext)method — not a bare function, and not free of the container's rules. ExecutionContextgives a guard the underlying request (viaswitchToHttp()/switchToWs()/switchToRpc()) and the exact handler/class Nest is about to invoke (viagetHandler()/getClass()).Reflector.getAllAndOverride()reads metadata attached with a custom decorator, checking the method before the class, and returns the first match — usegetAllAndMerge()when you want both to contribute instead.app.useGlobalGuards(new G())skips dependency injection entirely; use theAPP_GUARDprovider token for any global guard with constructor dependencies.- Guards compose as global → controller → method, and it's a strict AND: any single
falseor thrown exception stops the request before it reaches the next guard, any interceptor, or the handler.
The guard from the intro wasn't wrong about what it wanted to check — it was wrong about how it was born. new RolesGuard(...) and { provide: APP_GUARD, useClass: RolesGuard } compile to the same class doing the same check, and only one of them lets Nest's container do its job. That's the whole lesson of guards: they look like plain functions with a canActivate name, but every guarantee they can offer — real dependencies, testability, metadata lookups through ExecutionContext — depends on Nest actually building them. Next time a "protected" route turns out not to be, check the registration line before you touch the guard's logic.
What's the strangest guard bug you've chased down — a missing dependency, a metadata key typo, or something else? Drop it in the comments.
Runs right in your browser — poke at it and watch the concept react live.
Think it clicked? Take the 7-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
Keep reading
One post a day, in your inbox
Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.
0 comments
Sign in to join the discussion, like comments, and save articles for later.