Authorization bugs are the ones that don't show up in a stack trace. Nothing throws. The request just succeeds when it shouldn't have. That's what makes the pattern you choose matter more here than almost anywhere else in a codebase — the failure mode is silent.

The default I keep rejecting: checks inside the handler

The obvious starting point looks like this:

@Get(':id')
async getBooking(@Param('id') id: string, @CurrentUser() user: User) {
  const booking = await this.bookingsService.findOne(id)
 
  if (booking.ownerId !== user.id && user.role !== 'admin') {
    throw new ForbiddenException()
  }
 
  return booking
}

It works, and for a single endpoint it's fine. The problem is what happens at the tenth endpoint, written by someone else, six months later, who forgets the check — or writes a slightly different version of it, because there was never one place that defined "who can see a booking." Authorization logic that's duplicated across handlers isn't really a policy, it's a suggestion that happens to be enforced most of the time.

What I use instead: guards plus an explicit policy layer

The structure I default to now separates three concerns that the inline version above collapses into one:

1. Authentication — who is this request from. A standard NestJS guard reading a JWT, nothing unusual.

2. Role-level access — can this kind of user hit this kind of endpoint at all.

@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin', 'staff')
@Get()
async listBookings() {
  return this.bookingsService.findAll()
}

3. Resource-level policy — given a specific user and a specific record, is this particular action allowed. This is the part inline checks tend to skip, and it's the part that actually varies per resource:

@Injectable()
export class BookingPolicy {
  canView(user: User, booking: Booking): boolean {
    if (user.role === 'admin') return true
    return booking.ownerId === user.id
  }
}

The controller becomes declarative — decorators for "who can call this at all," a policy call for "and can they touch this specific record":

@Get(':id')
@UseGuards(JwtAuthGuard)
async getBooking(@Param('id') id: string, @CurrentUser() user: User) {
  const booking = await this.bookingsService.findOne(id)
 
  if (!this.bookingPolicy.canView(user, booking)) {
    throw new ForbiddenException()
  }
 
  return booking
}

That still has an explicit check in the handler — the difference is the rule now lives in exactly one place (BookingPolicy), tested in isolation, instead of being re-derived by whoever writes the next endpoint against that resource.

The trade-off I accepted

This is more files and more indirection than the inline version, and for a project with two roles and five endpoints, it's arguably overkill — you're paying an abstraction cost before you have the problem it solves. I still default to it, because the alternative's failure mode is worse than its convenience is valuable: the day you add a third role, or a "team member can view but not edit" distinction, is the day scattered inline checks turn into a bug that ships to production and doesn't announce itself. Centralizing the rule — not necessarily the enforcement call site — is the part I'm not willing to skip, even on small projects.