open atlas
↑ Back to track
NestJS, zero to senior NEST · 05 · 02

RBAC: roles, guards and the Reflector

RBAC lives in a guard: a @Roles() decorator stamps metadata, a RolesGuard reads it via the Reflector. But a role guard cannot enforce "edit only your own post" — ownership needs the loaded record, so it belongs in a policy guard or the service.

NEST Senior ◷ 18 min
Level
FoundationsJuniorMiddleSenior

A support ticket lands: a user deleted another user’s draft post. You check the code and it looks airtight — every write route is behind @Roles('editor') and a RolesGuard, and the offending user really is an editor. So the guard did its job perfectly: it confirmed the caller is an editor. What it never asked — what it structurally cannot ask — is whether this editor owns this specific draft. A role guard runs before the handler, with no record loaded; it knows the user’s roles and nothing about row 4192. The fix is not a bigger guard. It is understanding which authorization questions a guard can answer and which ones only the service layer can.

RBAC: a @Roles() decorator plus a guard that reads it

When you’ve seen authenticated routes quietly writable by any logged-in user — because someone forgot a guard — you understand why getting authorization right from the start matters more than patching it after the incident. Role-based access control maps each user to a set of roles, and each role to a set of permissions. In Nest the idiomatic encoding is metadata: a @Roles() decorator stamps the required roles onto a handler or controller, and a RolesGuard reads them back through the Reflector. The decorator is the smaller half — SetMetadata (or Reflector.createDecorator for a typed key) is all it takes:

// roles.decorator.ts
import { SetMetadata } from '@nestjs/common';
import { Role } from './role.enum'; // enum Role { Admin = 'admin', Editor = 'editor' }

export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);

The guard is where the decision lives. It reads the metadata with getAllAndOverride, comparing the handler’s metadata first and falling back to the controller’s, then checks the authenticated user’s roles:

// roles.guard.ts
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from './roles.decorator';
import { Role } from './role.enum';

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const required = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
      context.getHandler(), // route-level @Roles() wins...
      context.getClass(),   // ...over the controller default
    ]);
    if (!required) return true; // no @Roles() on this route -> open
    const { user } = context.switchToHttp().getRequest();
    const ok = required.some((role) => user?.roles?.includes(role));
    if (!ok) throw new ForbiddenException(); // clean 403 instead of a bare false
    return ok;
  }
}

Two senior details. First, getAllAndOverride(key, [getHandler(), getClass()]) returns the first defined value in that array — so a handler-level @Roles('admin') overrides a controller-wide default, which is exactly the precedence you want. (Its sibling getAllAndMerge would union them instead.) Second, returning false yields a generic 403 with no body; throwing ForbiddenException lets you ship a clean, message-bearing 403 — and your exception filter renders it uniformly.

Guard composition: authentication first, authorization second

A RolesGuard reads user.roles. That field does not exist until authentication has run and attached req.user. So order is not cosmetic — it is a correctness constraint: the authn guard (e.g. a JwtAuthGuard) must execute before the authz guard, or RolesGuard reads undefined.roles and either crashes or fails open.

When you bind both globally with the APP_GUARD provider token, Nest runs them in declaration order, so list authentication first:

// app.module.ts
import { APP_GUARD } from '@nestjs/core';

@Module({
  providers: [
    { provide: APP_GUARD, useClass: JwtAuthGuard }, // 1. establishes req.user
    { provide: APP_GUARD, useClass: RolesGuard },    // 2. reads user.roles
  ],
})
export class AppModule {}

This ordering also encodes a fail-closed default: authenticate globally, and a route that forgets to add @Roles() is still authenticated — it is merely open to any logged-in user, not open to the public. The dangerous inverse is binding authz per-route only: forget the binding on one handler and that route has no gate at all. A missing guard binding is a silent hole; prefer global authn plus explicit authz.

The senior limit: roles vs ownership vs policies

Here is the category error the hook hides. A RolesGuard runs in the inbound phase, before the handler, with no domain object loaded. It can evaluate any fact already on req.user — roles, tenant id, scopes. It cannot evaluate a fact that depends on a specific record, because that record has not been fetched yet. “Is this user an admin?” is answerable from req.user alone. “Does this user own post 4192?” requires SELECT ... WHERE id = 4192 and a comparison of post.ownerId to user.id — a query the guard has no business issuing and the route params alone cannot answer.

That is why resource/ownership authorization belongs where the subject is loaded: in the service method that already fetched the record, or in a policy guard that loads it deliberately. Pushing ownership into a role guard is not a tuning problem you can fix with more @Roles() — it is asking the wrong layer a question it has no data for.

// ownership lives in the service, where the record is already in hand
async update(postId: number, dto: UpdatePostDto, user: AuthUser) {
  const post = await this.posts.findOneOrFail(postId);
  if (post.ownerId !== user.id && !user.roles.includes(Role.Admin)) {
    throw new ForbiddenException('not your post'); // ownership, decided WITH the record
  }
  return this.posts.save({ ...post, ...dto });
}

When roles start to explode — editor, senior-editor, editor-but-only-drafts, editor-of-section-X — that is the signal to move from RBAC to policy-based / ABAC access. Instead of strings, you evaluate an ability against a subject. With CASL, an AbilityFactory builds per-user rules (including record-shaped conditions like { authorId: user.id }), and a PoliciesGuard + @CheckPolicies() evaluates a handler’s policy against that ability:

// casl-ability.factory.ts — rules can be record-shaped
can(Action.Update, Article, { authorId: user.id }); // own articles only
can(Action.Read, 'all');

// policies.guard.ts — evaluate the route's policy against the ability
@Injectable()
export class PoliciesGuard implements CanActivate {
  constructor(private reflector: Reflector, private abilityFactory: CaslAbilityFactory) {}

  async canActivate(ctx: ExecutionContext): Promise<boolean> {
    const handlers = this.reflector.get<PolicyHandler[]>(CHECK_POLICIES_KEY, ctx.getHandler()) ?? [];
    const { user } = ctx.switchToHttp().getRequest();
    const ability = this.abilityFactory.createForUser(user);
    return handlers.every((h) => (typeof h === 'function' ? h(ability) : h.handle(ability)));
  }
}

// controller — declare the policy, not a raw role
@Patch(':id')
@CheckPolicies((a: AppAbility) => a.can(Action.Update, 'Article'))
update() {/* ... */}

Note the honest caveat: a can(Action.Update, Article, { authorId: user.id }) rule only enforces ownership when CASL is handed a real Article instance to test. Checking can(Action.Update, 'Article') against the bare subject type still can’t see record 4192 — you either load the article first and test the instance, or the ownership check still lands in the service. ABAC moves the expressiveness up; it does not repeal the rule that you need the record to judge ownership.

Which layer owns which question?

QuestionNeeds the record?Right layerMechanism
Is the caller authenticated?NoAuthn guard (runs first)JwtAuthGuard sets req.user
Does the caller have a role?NoRolesGuard (after authn)@Roles() + Reflector.getAllAndOverride
May this role do this action on this type?NoPolicy guard (ABAC)PoliciesGuard + ability.can(action, Type)
Does the caller own THIS record?YesService, or policy guard that loads itload record, compare ownerId to user.id

The rule of thumb: every authorization question that can be answered from req.user and route metadata alone belongs in a guard; every question that depends on the contents of a specific row must wait until that row is loaded, which means the service layer or a deliberately resource-loading policy guard.

Why this works

Why can’t the guard just load the record itself? It can — but then it stops being a generic, reusable role gate and becomes coupled to one repository, one entity, and one id-extraction convention (is the id in the path? the body? nested?). You also pay the query twice if the handler re-loads the same record. A policy guard that loads the resource is a legitimate pattern, but most teams find the check is cleaner in the service, where the record is fetched once and the ownership comparison sits next to the mutation it guards. The coarse role gate stays a reusable guard; the record-shaped check goes where the record already lives.

Pick the best fit

The rule is: a user may edit only their OWN posts (admins may edit any). Where does this constraint get enforced?

Quiz

In the RolesGuard, what does reflector.getAllAndOverride(ROLES_KEY, [context.getHandler(), context.getClass()]) return, and why that array order?

Quiz

You bind a RolesGuard globally via APP_GUARD, but it crashes reading user.roles of undefined. What is the cause?

Recall before you leave
  1. 01
    Walk through the canonical RBAC setup in Nest: the @Roles() decorator, the RolesGuard, getAllAndOverride, and the ordering relative to authentication.
  2. 02
    Explain the senior limit: why a role guard cannot enforce 'a user may edit only their own post', and where that check belongs.
Recap

RBAC in Nest is metadata plus a guard: a @Roles() decorator (SetMetadata or Reflector.createDecorator) stamps required roles onto a handler or controller, and a RolesGuard reads them with reflector.getAllAndOverride(ROLES_KEY, [getHandler(), getClass()]) — returning the first defined value so a handler-level @Roles() overrides the controller default, then comparing against req.user.roles and throwing ForbiddenException for a clean 403. Order is a correctness constraint, not a style choice: req.user is only attached after authentication, so the JwtAuthGuard must run before the RolesGuard; with APP_GUARD providers Nest runs guards in declaration order, which also yields a fail-closed default where a route that forgets @Roles() is still authenticated rather than public. The senior limit is the heart of the lesson: a guard runs before the handler with no record loaded, so it can answer “is this user an editor?” but structurally cannot answer “does this editor own THIS draft?” — that question needs the row fetched and post.ownerId compared to user.id, which is why ownership authorization lives in the service (next to the mutation) or in a deliberately resource-loading policy guard, never in a role guard. When roles multiply into record-shaped variants, move to policy-based/ABAC access with CASL — an AbilityFactory, a PoliciesGuard, and @CheckPolicies evaluating can(action, subject) — remembering that even ABAC must be handed the real record instance to enforce ownership. Now when you see a support ticket “user deleted someone else’s content,” your first instinct should be: did the guard check a role or did it check the record? Those are different questions, and only one of them can live in a guard.

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.

recallapplystretch0 of 5 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.