Skip to content

Guards and Authorization

Aman Kumar Singh8 min read
Part 6 of 40From the NestJS Production Guide series
Guards and Authorization — article by Aman Kumar Singh

Last time in the NestJS Production Guide, I covered DTOs and Validation Pipes. That piece is about whether a request is well-formed. Guards answer a different question: whether the caller is allowed to make that request at all. People conflate validation and authorization often enough that the boundary between them deserves to be spelled out.

A pipe can tell you that planId is a valid UUID. It has no idea whether the authenticated user is allowed to touch that plan, or whether they're even authenticated in the first place. That's the guard's job, and NestJS gives it its own well-defined slot in the request lifecycle for exactly that reason.

I want to walk through how guards fit into that lifecycle, and how to build authentication and role checks that don't turn into copy-pasted if statements across every controller. I'll also get into where teams get this wrong, often in ways that don't surface until an audit or an incident.

Where guards sit in the request lifecycle

NestJS runs guards after middleware but before interceptors, pipes, and the route handler itself. That ordering matters. Middleware doesn't have access to the execution context NestJS builds around a request, so it can't tell you which controller method is about to run or what metadata is attached to it via decorators. Guards can.

A guard implements a single method, canActivate, which returns a boolean, a Promise<boolean>, or an Observable<boolean>. Return true and the request proceeds. Return false, or throw, and NestJS stops it there, before any pipe touches the body and before the handler runs. This is the right place to reject a request outright, because nothing downstream has done any work yet.

// src/common/guards/jwt-auth.guard.ts
import { Injectable, ExecutionContext } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  handleRequest<TUser = any>(err: unknown, user: TUser, info: unknown, context: ExecutionContext): TUser {
    if (err || !user) {
      throw err instanceof Error ? err : new Error('Unauthorized');
    }
    return user;
  }
}

This one wraps Passport's JWT strategy in a guard NestJS can apply per-route, per-controller, or globally. The important detail is handleRequest: it's the hook where you decide what an authentication failure actually looks like, rather than letting Passport's default behavior leak through unexamined.

Authentication guards versus authorization guards

It helps to keep these as two distinct guards rather than one guard doing both jobs. Authentication answers "who is this," authorization answers "are they allowed to do this specific thing." Collapsing them into a single guard tends to produce a class that's hard to reuse, because the authentication logic is the same everywhere but the authorization rule changes per route.

A roles guard is a clean example of the second kind. It doesn't verify identity, it reads a role requirement off the route (set via a custom decorator) and checks it against the user that a prior guard already attached to the request.

// src/common/decorators/roles.decorator.ts
import { SetMetadata } from '@nestjs/common';

export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
// src/common/guards/roles.guard.ts
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from '../decorators/roles.decorator';

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

  canActivate(context: ExecutionContext): boolean {
    const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);

    if (!requiredRoles || requiredRoles.length === 0) {
      return true;
    }

    const request = context.switchToHttp().getRequest();
    const user = request.user;

    if (!user) {
      return false;
    }

    return requiredRoles.some((role) => user.roles?.includes(role));
  }
}

Reflector.getAllAndOverride checks the handler first and falls back to the controller class, which lets you set a default at the controller level and override it on individual routes without duplicating the decorator everywhere. Applying both guards to a route looks like this:

// src/modules/billing/billing.controller.ts
import { Controller, Post, UseGuards, Param } from '@nestjs/common';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RolesGuard } from '../../common/guards/roles.guard';
import { Roles } from '../../common/decorators/roles.decorator';
import { BillingService } from './billing.service';

@Controller('billing')
@UseGuards(JwtAuthGuard, RolesGuard)
export class BillingController {
  constructor(private readonly billingService: BillingService) {}

  @Post(':orgId/refund')
  @Roles('billing_admin', 'owner')
  refund(@Param('orgId') orgId: string) {
    return this.billingService.refund(orgId);
  }
}

UseGuards runs guards in order, so JwtAuthGuard populates request.user before RolesGuard reads it. Get the order wrong and the roles guard has nothing to check against.

Ownership and resource-level checks

Role checks answer one question: can users with this role hit this endpoint. They leave a second, more specific question untouched: does this particular user own this particular resource. A billing_admin role check on a refund endpoint doesn't stop one organization's billing admin from refunding another organization's invoice, if the only thing gating the route is the role.

This is where a lot of authorization bugs live, because it's tempting to treat role-based access control as the whole story. It's necessary. On its own, it isn't enough for a multi-tenant system. The fix is a guard, or a check inside the service layer, that loads the resource and compares its tenant or owner field against the authenticated user's context.

// src/common/guards/org-ownership.guard.ts
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { OrganizationsRepository } from '../../modules/organizations/organizations.repository';

@Injectable()
export class OrgOwnershipGuard implements CanActivate {
  constructor(private readonly organizationsRepository: OrganizationsRepository) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest();
    const user = request.user;
    const orgId = request.params.orgId;

    const membership = await this.organizationsRepository.findMembership(orgId, user.id);

    if (!membership) {
      throw new ForbiddenException('You do not have access to this organization');
    }

    request.membership = membership;
    return true;
  }
}

Notice this guard hits the database. That's a real cost, and it's worth being deliberate about it rather than treating every guard as free. For a request that's already fetching the resource in the handler anyway, some teams skip the extra guard-level query and do the ownership check in the service, right before the mutation, using data already in hand. Either placement is defensible. What isn't defensible is skipping the check because a role guard higher up felt like enough.

Custom decorators for cleaner controllers

Reading request.user directly in a controller works, but it couples the controller to the shape of the request object and to wherever the guard happened to attach the user. A parameter decorator hides that detail and gives you one place to change it if the user shape evolves.

// src/common/decorators/current-user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';

export const CurrentUser = createParamDecorator(
  (data: unknown, context: ExecutionContext) => {
    const request = context.switchToHttp().getRequest();
    return request.user;
  },
);
@Post(':orgId/refund')
@Roles('billing_admin', 'owner')
refund(@Param('orgId') orgId: string, @CurrentUser() user: AuthenticatedUser) {
  return this.billingService.refund(orgId, user.id);
}

This is a small thing, but it pays off the moment you need to change how the user object is populated, say moving from a plain JWT payload to a richer session object pulled from Redis. One decorator to update instead of grepping every controller for request.user.

Global guards and the tradeoff of defaulting to locked down

APP_GUARD lets you register a guard globally, applied to every route in the application unless explicitly overridden.

// src/app.module.ts
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';

@Module({
  providers: [
    {
      provide: APP_GUARD,
      useClass: JwtAuthGuard,
    },
  ],
})
export class AppModule {}

The case for this is straightforward: a new route added by anyone on the team is authenticated by default, and someone has to explicitly opt out rather than explicitly opt in. Given how often "we forgot to add the guard" shows up as the root cause of an exposed endpoint, defaulting to locked down is the safer failure mode.

The cost is that public routes, health checks, webhooks, a login endpoint itself, need an explicit bypass. A Public() decorator combined with a check in the global guard handles this cleanly:

// src/common/decorators/public.decorator.ts
import { SetMetadata } from '@nestjs/common';

export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
canActivate(context: ExecutionContext): boolean {
  const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
    context.getHandler(),
    context.getClass(),
  ]);

  if (isPublic) {
    return true;
  }

  return super.canActivate(context) as boolean;
}

Keep the list of public routes small and reviewed. A Public() decorator is easy to add and easy to forget to remove, and it's one of the first things worth grepping for in a security review.

Production pitfalls

The most common issue is treating role checks as sufficient for multi-tenant authorization, without an ownership or membership check underneath. A role tells you what a user is generally permitted to do; it says nothing about which specific rows they're permitted to touch. Both checks need to exist, usually as separate guards or a guard plus a service-level assertion.

The second is guard ordering. UseGuards(RolesGuard, JwtAuthGuard) runs the roles check before authentication has populated request.user, which either throws on a null reference or silently passes because the roles check couldn't find anything to compare against. Order guards the way you'd order middleware: identity first, then permission.

The third is doing expensive work inside a guard without thinking about it. A guard that hits the database or an external service runs on every single request that matches its route, including retries and polling clients. If that check can be cached, for example a membership lookup that changes rarely, put it behind Redis with a short TTL rather than hitting PostgreSQL on every request. If it can't be cached because staleness is unacceptable for that resource, at least make sure the query is indexed and fast.

The fourth is confusing "guard passed" with "the resource is safe to return." A guard can confirm a user belongs to an organization without confirming the specific record they're requesting belongs to that same organization. That secondary check often needs to happen in the query itself, scoping every SELECT by tenant ID rather than relying on the guard alone to have caught everything.

Key takeaways

  • Guards run after middleware but before pipes and the handler, and they're the right place to reject unauthorized requests before any other work happens.
  • Keep authentication and authorization as separate guards. Identity and permission are different questions and reusing one guard for both tends to get harder to maintain.
  • Role checks are necessary but not sufficient in multi-tenant systems. Pair them with an ownership or membership check scoped to the specific resource.
  • Order matters when combining guards with `UseGuards`; authentication guards must run before anything that reads the authenticated user.
  • `APP_GUARD` with an explicit `Public()` decorator defaults new routes to locked down, which is a safer failure mode than defaulting to open.
  • Guards that hit the database on every request have a real cost. Cache what you reasonably can and keep the rest indexed.

Frequently asked questions

What's the difference between a guard and a middleware in NestJS?

Middleware runs before NestJS builds its execution context, so it has no access to the target handler, controller, or route metadata. Guards run inside that context and can read decorator metadata via `Reflector`, which is what makes role and permission checks practical to implement as guards rather than middleware.

Can a guard modify the request object?

Yes. A common pattern is attaching data the handler will need, such as `request.user` from an auth guard or `request.membership` from an ownership guard, so downstream code doesn't have to re-fetch it.

Should authorization logic live in guards or in the service layer?

Route-level checks, like "does this role have access to this endpoint," belong in guards because they can reject the request before any handler code runs. Resource-level checks that depend on data only the service already has loaded are often cheaper and clearer inside the service, right before the mutation.

How do I test a guard in isolation?

Instantiate the guard directly with its constructor dependencies mocked, build a fake `ExecutionContext` (Nest's testing utilities or a hand-rolled object with `switchToHttp` and `getHandler` stubbed), and call `canActivate` directly. This avoids spinning up the whole HTTP pipeline just to verify a boolean.

Is it safe to rely only on RolesGuard for multi-tenant data?

No. A role guard confirms what a user is generally permitted to do; it doesn't confirm the specific resource in the request belongs to that user's organization. Always pair role checks with a tenant-scoped query or a dedicated ownership guard.

Does using a global APP_GUARD hurt performance?

The guard itself runs on every request regardless of whether it's registered globally or per-route; the difference is only where it's declared. The performance question is about what the guard does, not where it's applied. A cheap JWT verification is negligible; an uncached database lookup on every request is where the cost actually shows up.

Related articles

Multi-Factor Authentication (MFA) — Aman Kumar Singh
JWT Authentication in NestJS — Aman Kumar Singh
Security Best Practices — Aman Kumar Singh
Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.