Fullstack CourseLearn by building
Back to week 3

Topic

RBAC & guards

Definition

Role-based access control (RBAC) grants permissions according to a role assigned to an authenticated subject, and a Nest guard is the component that reads that role — or other request context — to decide whether a request may reach a route handler.

In simpler words

RBAC says “admins can do this, members can’t”; a guard is the code that checks that rule before your controller runs.

A common Nest setup runs two guards globally on every request: JwtAuthGuard decides who you are, RolesGuard decides what your role allows.

After this you can

  • Read @Roles and predict which role passes
  • Explain why RolesGuard needs JwtAuthGuard to run first
  • Choose 401 vs 403 for a failing check

Two guards, two questions

Definition

A Nest guard implements canActivate and returns whether the current execution context may proceed, commonly using Reflector to read metadata — such as required roles — attached by decorators on the handler or class.

In simpler words

JwtAuthGuard answers “is there a valid session at all?”; RolesGuard answers “does that session’s role satisfy this route’s @Roles list?”

Both guards are registered globally via APP_GUARD in AuthModule, so every route is protected by default — a route opts out with @Public(), and a route narrows further with @Roles(...).

Order matters: RolesGuard reads request.user, so it only works because JwtAuthGuard already ran and attached that user during the same guard phase.

Admin-only delete

@Delete(':id')
@Roles('admin')
@HttpCode(HttpStatus.NO_CONTENT)
async remove(@Param('id', ParseUUIDPipe) id: string) {
  await this.ticketsService.remove(id);
}

A member sees 403 here; an admin sees 204. Every other ticket route has no @Roles, so both roles may call it once authenticated.

Picking the right guard failure

Definition

A guard should reject a request with the HTTP status that matches the failed check: unauthenticated requests receive 401 Unauthorized, and authenticated-but-disallowed requests receive 403 Forbidden.

In simpler words

401 means “prove who you are”; 403 means “I know who you are, and the answer is still no.”

JwtAuthGuard.handleRequest throws UnauthorizedException when Passport could not validate a user — no cookie, an expired token, or an unknown subject all land here.

RolesGuard throws ForbiddenException only after it already has request.user and finds the role missing from the required list — never for a request with no session at all.

RolesGuard check

if (!user) throw new UnauthorizedException();
if (!roles.includes(user.role)) {
  throw new ForbiddenException('Insufficient permissions');
}

Guarding against a missing user first keeps the 401/403 distinction correct even if RolesGuard ran without JwtAuthGuard.

UI hide ≠ authorization; never trust body.role

Definition

Authorization is a server-side decision based on the authenticated subject and route metadata; client UI state and client-supplied role fields are not security controls.

In simpler words

Hiding a Delete button is polish. Nest guards are the boundary. A forged JSON body claiming role: "admin" must never grant power.

Roles come from the verified user record / trusted JWT claims after JwtAuthGuard — not from the request body.

Frontend checks improve UX (disable buttons early) but any attacker can call the API directly with curl/Postman.

When adding a dangerous route, ask: which @Roles decorate it, and what Postman proof shows a member gets 403?

Mistake: trust the body

// Wrong
@Post('tickets/:id/assign-admin')
assign(@Body() body: { role: string }) {
  if (body.role === 'admin') return this.promote();
}

// Right
@Post('tickets/:id/dangerous')
@Roles('admin')
dangerous(@CurrentUser() user: User) {
  return this.ticketsService.dangerous(user);
}

Attackers choose the body. Guards read the authenticated user Nest already verified.

Mistake: frontend-only authz

// Wrong — only React hides the button
{user.role === 'admin' && <DeleteButton />}

// Right — hide for UX AND enforce with @Roles('admin') on Nest

Both layers matter; only Nest is authoritative.

Keep in mind

  • Keep the role set small (admin | member here) — a growing role matrix belongs in a permissions table, not more guards.
  • Prefer guard-level checks over scattering role comparisons inside services.
  • 401 for “who are you”, 403 for “not for you” — never swap them for convenience.
  • UI checks are optional; Nest @Roles checks are mandatory for privileged routes.

Test

Check your understanding

At least 10 questions — mix of concept, syntax, practical, and logic. Score ≥80% (enforced by the API) to save progress.

Checking your session…

15 questions · concept 5 · syntax 3 · practical 4 · logic 3

1. How does RolesGuard decide?
Concept
2. What status for authenticated user lacking required role?
Syntax
3. Are JwtAuthGuard and RolesGuard global here?
Practical
4. What if @Roles is missing on a route?
Logic
5. Why order JwtAuthGuard before role checks?
Concept
6. Is hiding a Delete button in React authorization?
Practical
7. Should the client send role in the JSON body for authz?
Syntax
8. What does @Roles('admin') mean?
Logic
9. Where is role stored for comparison?
Concept
10. What if JwtAuthGuard fails first?
Practical
11. A logged-in member (role: member) calls this route. What happens?
Conceptintermediate
@Roles('admin')
@Delete(':id')
remove(@Param('id') id: string) {}
12. Why is this authorization check insecure?
Syntaxadvanced
if (req.body.role === 'admin') {
  return this.svc.removeAll();
}
13. What does registering both guards as APP_GUARD achieve?
Practicalintermediate
providers: [
  { provide: APP_GUARD, useClass: JwtAuthGuard },
  { provide: APP_GUARD, useClass: RolesGuard },
]
14. This throws "cannot read role of undefined" for some requests. Why?
Logicadvanced
// RolesGuard.canActivate
const { user } = ctx.switchToHttp().getRequest();
return roles.includes(user.role);
15. Who can access this route, given RolesGuard is global and no @Roles is present?
Conceptintermediate
@Get('me')
me(@CurrentUser() user: User) {
  return user;
}

Checking your session…