← Back to the section

After the JWT is verified and the user's authenticity is established, the next question arrives: what is this user allowed to do? This is where authorization begins. The most common approach is RBAC.

What RBAC is and what the problem is without it

RBAC stands for Role-Based Access Control. The idea is simple: each user is assigned a role (customer, admin, seller), and access to specific actions depends on the role, not on the specific person.

Without RBAC, developers often write checks right inside the handlers:

async createOrder(userId: string) {
  const user = await this.users.findById(userId);
  if (user.type !== 'customer') throw new Error('Forbidden');
  // ...
}

There end up being many such checks, scattered across the code, and it's easy to miss something. RBAC moves this logic to one level — into a Guard that runs before any handler.

Where roles come from: JwtStrategy.validate

The user's roles arrive in the JWT token. In Keycloak they live in the realm_access.roles field:

{
  "sub": "user-42",
  "realm_access": {
    "roles": ["customer"]
  }
}

In a standard OAuth2 server the roles may be in the scope field as a space-separated string.

The only place where we parse the token and extract the roles is the validate method in JwtStrategy. It returns a Principal object, which NestJS puts into request.user and makes available in all guards and controllers:

export interface JwtClaims {
  sub: string;
  realm_access?: { roles: string[] };
  scope?: string;
}

export interface Principal {
  sub: string;
  roles: string[];
}

function extractRoles(claims: JwtClaims): string[] {
  if (claims.realm_access?.roles) {
    return claims.realm_access.roles;
  }
  return claims.scope ? claims.scope.split(' ') : [];
}

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(config: AppConfig) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      algorithms: ['RS256'],
      audience: config.auth.audience,
      issuer: config.auth.issuer,
      secretOrKeyProvider: passportJwtSecret({
        jwksUri: config.auth.jwksUri,
        cache: true,
        cacheMaxAge: 300_000,
      }),
    });
  }

  validate(claims: JwtClaims): Principal {
    return { sub: claims.sub, roles: extractRoles(claims) };
  }
}

An important detail: unlike Spring Security, there is no ROLE_ prefix here. Roles are compared as ordinary strings — 'customer', not 'ROLE_CUSTOMER'.

RolesGuard and the @Roles decorator

A Guard in NestJS is a class that decides: let the request through or return an error. RolesGuard reads which roles the endpoint requires (through the decorator metadata) and compares them with what the user has:

export const Roles = Reflector.createDecorator<string[]>();

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

  canActivate(ctx: ExecutionContext): boolean {
    const required = this.reflector.get(Roles, ctx.getHandler());
    if (!required) {
      throw new ForbiddenException();
    }
    const { user } = ctx.switchToHttp().getRequest<{ user: Principal }>();
    if (!required.some((r) => user.roles.includes(r))) {
      throw new ForbiddenException();
    }
    return true;
  }
}

Notice: if a method has no @Roles(...) and no @Public() marker, the guard throws ForbiddenException. This is a safeguard against an accidentally unprotected endpoint — silently letting everyone through would be more dangerous.

For public endpoints (for example, GET /products) you need a separate @Public() marker:

export const IS_PUBLIC = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC, true);

The order of guard registration matters

Guards are registered globally through APP_GUARD. The order is mandatory: first JwtAuthGuard checks the token and returns 401 if it's missing, and only then RolesGuard checks the roles and returns 403:

@Module({
  providers: [
    { provide: APP_GUARD, useClass: JwtAuthGuard },  // 401: no token
    { provide: APP_GUARD, useClass: RolesGuard },     // 403: no required role
  ],
})
export class AppModule {}

If you swap them, RolesGuard gets request.user = undefined and breaks.

How to apply @Roles in a controller

The decorator is put on each method. You can specify several roles — the guard lets the request through if the user has at least one of them:

@Controller('orders')
export class OrderController {
  constructor(private readonly dispatcher: UseCaseDispatcher) {}

  @Post()
  @Roles(['customer'])
  create(
    @Body() body: CreateOrderRequest,
    @CurrentUser() principal: Principal,
  ): Promise<OrderResponse> {
    return this.dispatcher.dispatch(new CreateOrderCommand(body, principal));
  }

  @Get(':id')
  @Roles(['customer', 'admin'])
  findById(
    @Param('id') id: string,
    @CurrentUser() principal: Principal,
  ): Promise<OrderResponse> {
    return this.dispatcher.dispatch(new GetOrderByIdQuery(id, principal));
  }
}

@CurrentUser() is a helper decorator that pulls out request.user:

export const CurrentUser = createParamDecorator(
  (_: unknown, ctx: ExecutionContext): Principal =>
    ctx.switchToHttp().getRequest<{ user: Principal }>().user,
);

So that the role strings don't scatter across the whole codebase, it's convenient to move them into constants:

export const ROLES = {
  CUSTOMER: 'customer',
  SELLER: 'seller',
  ADMIN: 'admin',
  SYSTEM: 'system',
} as const;

How many roles you need and when to stop

A typical minimal role catalog for a product application:

RoleWhoWhat they do
customerEnd userCreates and reads their own orders
sellerSellerManages their own products, sees orders for their items
adminInternal operatorFull access, every action goes into the log
systemAnother serviceInter-service calls via Client Credentials

When a team starts adding customer-premium, partner-seller, junior-admin — this is usually a sign that RBAC is trying to solve a task beyond its power. Let's go through the common cases:

  • "VIP customers have different limits" — this isn't a new role but a customer.tier attribute. It's checked in the command handler by the field value.
  • "B2B customers work with wholesale products" — most likely this is a separate context with its own model, not a new role.
  • "A manager sees a customer's data but can't delete it" — this is a permissions system, not RBAC.

The fewer roles, the easier they are to maintain. When there are many roles, they start to overlap and contradict each other.

RBAC doesn't answer every question — and that's fine

RBAC handles one specific question well: "can the customer role call this endpoint at all?"

But it doesn't solve the question: "can this specific customer cancel exactly this order?" That's already ABAC (Attribute-Based Access Control) — a check at the level of a specific resource.

// Controller: RBAC — endpoint-level
@Post(':id/cancel')
@Roles(['customer', 'admin'])
cancel(@Param('id') id: string, @CurrentUser() principal: Principal) {
  return this.dispatcher.dispatch(new CancelOrderCommand(id, principal));
}

// Handler: ABAC — resource-level
async execute(cmd: CancelOrderCommand): Promise<void> {
  const order = await this.orders.byId(cmd.orderId);
  if (!cmd.principal.roles.includes('admin') && order.customerId !== cmd.principal.sub) {
    throw new ForbiddenError(cmd.orderId);
  }
  order.cancel();
  await this.orders.save(order);
}

Both layers are needed: the @Roles on the outside does not replace the owner check inside the handler.

In short

  • RBAC is access control by role, implemented through RolesGuard and @Roles in NestJS.
  • Roles are extracted from the JWT once in JwtStrategy.validate and end up in Principal.roles.
  • Keycloak puts roles in realm_access.roles, standard OAuth2 — in scope.
  • Guards are registered through APP_GUARD: authentication first (401), then authorization (403).
  • An endpoint without @Roles and without @Public() — the guard blocks every request, even an authenticated one.
  • The minimal role catalog: customer, seller, admin, system. More roles is a sign of a design problem.
  • RBAC answers the question "can the role call the endpoint", ABAC — "can this user work with this resource".
  • JWT validation in NestJS — how to set up JwtStrategy with key verification.
  • ABAC: resource ownership — the next layer after RBAC.
  • Audit of admin commands — why the admin role always requires an action log.
  • Service-to-service — Client Credentials and the system role.