Skip to content
Knowledge Hub

Custom decorators and Reflector in NestJS: why @Public() on a controller has no effect Intermediate

Estimated reading time: 9 min read

An order API has a globally registered authentication guard. GET /catalog must be public, GET /orders/101 requires the support role, and POST /orders/101/refund is admin only. Handlers read req.user directly in dozens of places.

The team adds @Public() to CatalogController, but requests without a token still get 401. With @Roles(['support']) on the class and @Roles(['admin']) on refund(), it is unclear whether user sam (role support) may issue refunds.

This article answers: where does a decorator store its information, how does the guard read it back, and does the handler override or merge with the class?

Situation Choose Condition
On/off flag (@Public()), method beats class getAllAndOverride(key, [handler, class]) Read both handler and class
Permission list, method more specific than class getAllAndOverride The method fully replaces the class requirement
Cumulative list (tags, extra scopes) getAllAndMerge Check for an empty array, don’t use it as a boolean
A new metadata decorator Reflector.createDecorator<T>() Nest ≥ 10.2; auto-generated key, no collisions
Getting the user in a handler createParamDecorator → @CurrentUser() A guard has already attached req.user
Bundling decorators that always go together applyDecorators(...) No conditional logic inside

With experimentalDecorators (the configuration NestJS uses), a decorator is just a function. TypeScript calls it once, when the class is defined, i.e. when the file is loaded, not on every request. The demo prints this line before any app is created:

[class definition] decorator runs for OrdersController.get
[class definition] done, no app has been created yet

So a decorator knows nothing about the request or the user. Its job is to write metadata: SetMetadata(key, value) calls Reflect.defineMetadata (from the reflect-metadata library). On a method, the metadata attaches to the handler function (descriptor.value); on a class, it attaches to the constructor.

Component Runs at Job
SetMetadata / Reflector.createDecorator Class definition Write key → value onto the handler or class
Reflector (a provider from @nestjs/core) Every request, inside guards/interceptors Read metadata by key
createParamDecorator Definition: registers the parameter; request: runs the factory Produces a handler parameter value
applyDecorators Class definition Calls several decorators in turn

The common ways to read with Reflector. The examples below are real results with refund() carrying [admin] and the class carrying [support]:

Call Result Rule
get(Roles, handler) ['admin'] A single target only
get(Roles, class) ['support'] A single target only
getAllAndOverride(Roles, [handler, class]) ['admin'] The first non-undefined value in array order
getAllAndMerge(Roles, [handler, class]) ['admin', 'support'] Arrays are concatenated, objects shallow-merged ({...a, ...b})

The [handler, class] order is a convention, not an implicit default. Swap it to [class, handler] and the class wins.

The diagram answers: when is the metadata written, and when sam calls refund(), which value does the guard base its decision on?

The decorator writes metadata once; RolesGuard reads handler before class, override yields 403 while merge yields 200
The decorator writes metadata once; RolesGuard reads handler before class, override yields 403 while merge yields 200Step 1/7: The controller file loads: the decorators run, writing roles [support] on the class and [admin] on refund().At class definition(once)Every request@Roles(...)class + refund()Metadatareflect-metadata2 entriesReflectorgetAllAndOverrideRequesttoken samRolesGuardAPP_GUARDrefund()handlerdefineMetadata
@Roles(...) → MetadatadefineMetadata
Step 1/7

The controller file loads: the decorators run, writing roles [support] on the class and [admin] on refund().

Step Metadata Guard sees Result
1. File loaded class [support], refund [admin] No guard yet
2–3. Bootstrap, request arrives Unchanged req.user = sam (roles [support])
4–5. Override Unchanged [admin] (stops at the handler) 403
6–7. Merge Unchanged [admin, support] 200

Metadata does not change per request. To decide based on the user, the guard combines metadata with req.user; it never writes back into the metadata.

Decorator code from the demo:

export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
export const Roles = Reflector.createDecorator<string[]>();
export const AdminOnly = () => applyDecorators(Roles(['admin']), HttpCode(200));
export const CurrentUser = createParamDecorator((data: keyof User | undefined, ctx: ExecutionContext) => {
const req = ctx.switchToHttp().getRequest<{ user?: User }>();
return data ? req.user?.[data] : req.user;
});

The guard reads it back:

// AuthGuard (APP_GUARD)
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [ctx.getHandler(), ctx.getClass()]);
if (isPublic) return true;
// RolesGuard: pass the decorator itself, the return type is string[]
const required = this.reflector.getAllAndOverride(Roles, [ctx.getHandler(), ctx.getClass()]);

Reflector.createDecorator<string[]>() (added in v10.2.0, PR #12237) returns a decorator with a KEY property. If you pass no key, Nest generates a random 21-character string, so two modules cannot collide on a key.

Pass the decorator into reflector.get(Roles, ...) and TypeScript infers string[], with no manual generic needed.

  • ExecutionContext.switchToHttp().getRequest() returns the adapter’s request (Express by default). For GraphQL or microservices the context differs; getHandler() and getClass() still work.
  • Pipes apply to custom parameters just like @Body(). But ValidationPipe ignores them by default; you need validateCustomDecorators: true.
  • createParamDecorator takes pipes after data: @CurrentUser('id', ParseIntPipe), or the pipe in first position if there is no data.
  • createDecorator called with no argument stores {} (code: value ?? {}), which is truthy. Boolean flags should pass an explicit value or use SetMetadata(key, true).
  • getAllAndMerge with a single primitive value returns an array ([true]); with no values it returns [].
  • A global guard registered via APP_GUARD gets Reflector injected by DI. Registered via app.useGlobalGuards(new AuthGuard(...)), you must pass Reflector yourself (see DI container).

@CurrentUser() runs at the pipe step, after every guard. The diagram shows why guards cannot use this decorator:

Loading diagram…

The orders-demo.ts demo (NestJS 11.2.6) builds a real app, calls listen(0), and sends requests with fetch. The token is the user name (demo only):

== AuthGuard reads handler + class, RolesGuard uses getAllAndOverride
GET /catalog - -> 200 {"items":["sku-1","sku-2"]}
GET /orders/health - -> 200 {"ok":true}
GET /orders/101 - -> 401 {"message":"Unauthorized","statusCode":401}
GET /orders/101 alice -> 403 {"message":"requires one of [support]",...}
GET /orders/101 sam -> 200 {"id":"101","viewedBy":"sam"}
POST /orders/101/refund sam -> 403 {"message":"requires one of [admin]",...}
POST /orders/101/refund root -> 200 {"id":"101","refundedBy":"root"}
GET /orders/me sam -> 200 {"id":2}
GET /orders/me ghost -> 400 {"message":["id must be an integer number"],...}
== RolesGuard uses getAllAndMerge
POST /orders/101/refund sam -> 200 {"id":"101","refundedBy":"sam"}
== Bug: AuthGuard reads only the handler
GET /catalog - -> 401 {"message":"Unauthorized","statusCode":401}
GET /orders/health - -> 200 {"ok":true}

Three takeaways:

  1. @Public() on the class (/catalog) and on a method (/orders/health) both work only if the guard reads [handler, class].
  2. Same metadata, override gives sam a 403, merge gives a 200. The permission semantics live in how the guard reads, not in the decorator.
  3. @CurrentUser(new ValidationPipe({ validateCustomDecorators: true })) blocks a broken claim (id: 'x') with a 400.
Terminal window
npx --prefix examples ts-node examples/src/nestjs-custom-decorators/orders-demo.ts

Pros

  • Access rules sit next to the route; reading the code shows which routes are public and which roles they need.

  • One global guard handles every route; handlers don’t repeat if (!req.user).

  • createDecorator gives strict types and collision-free keys.

  • @CurrentUser() decouples handlers from the Express req, making them easy to test.

  • < slot="cons">
  • Hidden rules: a missing decorator blocks (or exposes) a route with no compile error.

  • Override/merge semantics are decided by the guard; changing the guard changes permissions on every route.

  • Metadata is static and cannot express data-based permissions (orders belonging to this user).

  • Stacked decorators are hard to debug when you don’t know the read order.

Cons

Data-based permissions (a user may only view their own orders) still have to be checked in the service; see Secure coding.

  1. The global guard reads metadata with [ctx.getHandler(), ctx.getClass()], with tests for @Public() on both class and method.
  2. Choose override or merge by business meaning, and document it in the guard’s comment.
  3. Use Reflector.createDecorator<T>() for new decorators; for string keys, add a module prefix (orders:roles).
  4. Deny by default: routes without metadata still go through authentication.
  5. Enable validateCustomDecorators if you want ValidationPipe to check values from @CurrentUser().
  6. Bundle repeated decorator sets (Roles + HttpCode + Swagger) with applyDecorators.
  7. Have an e2e test listing public routes, to catch routes exposed when a new decorator is added.

The most common mistake is a global guard that reads only the handler, exactly like the “Bug” part of the demo:

@Public() on a controller is ignored

Common

const isPublic = this.reflector.get<boolean>(IS_PUBLIC_KEY, ctx.getHandler());

Prefer

const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [ctx.getHandler(), ctx.getClass()]);

Why

@Public() on a class writes metadata to the constructor, not to each method. Reading only the handler leaves /catalog at 401.

The second mistake is more dangerous because it opens routes instead of blocking them:

Misconception Consequence What to do
Decorators run on every request Per-user logic placed in a decorator Decorators only write metadata; logic lives in the guard
Only my module uses the 'roles' key Another library overwrites it and the guard reads the wrong value createDecorator or a key prefix
Using @CurrentUser() in a guard Not available: the param factory runs after guards The guard reads req.user
Merge is safer than override Merge widens permissions (sam can refund) Choose by meaning, with tests
  1. With targets swapped to [ctx.getClass(), ctx.getHandler()], what status does sam get calling refund under override?
  2. Why does @Public() on a class have no effect when the guard uses get(..., ctx.getHandler())?
  3. Why can @Roles() decorators from createDecorator in two different modules never overwrite each other’s metadata?
  4. A guard needs the user id. Where does it read it from, and why not from @CurrentUser()?