Skip to content
NestJS ns guards 5 min read

Policy & Attribute-Based Authorization

Role-based access control answers “what role is the user?” but real applications need to answer “can this user act on this resource?”. Attribute-based access control (ABAC) makes authorization decisions from the attributes of the subject, the action, and the resource itself — for example, “an author may update only the articles they own.” In NestJS the idiomatic way to model this is CASL, an isomorphic permission library, wired into a PoliciesGuard driven by a @CheckPolicies decorator. This page shows how to define abilities, build the guard, and enforce attribute-driven rules per route.

Why CASL over plain role checks

A pure RBAC guard hard-codes a roles → routes mapping. As soon as ownership, tenancy, or field-level conditions appear, that mapping explodes. CASL lets you declare abilities as data — can/cannot rules with conditions — and then ask ability.can(action, subject) at any point. The same ability object works on the server, in tests, and even shared with the frontend to hide UI.

ConcernRBAC guardCASL / ABAC
Decision inputRole stringSubject + action + resource attributes
Ownership rulesManual if checks in servicesDeclarative conditions in ability
Field restrictionsNot supportedcan('update', 'Article', ['title'])
Reuse on frontendDuplicated logicSame ability serialized to client

Install the dependency:

npm install @casl/ability

Defining the ability factory

Model your actions and subjects, then build a factory that produces an AppAbility per user. Conditions reference resource attributes (here, authorId) that CASL matches against the object passed to can.

// casl/ability.factory.ts
import { Injectable } from '@nestjs/common';
import {
  AbilityBuilder,
  createMongoAbility,
  MongoAbility,
  ExtractSubjectType,
  InferSubjects,
} from '@casl/ability';
import { Article } from '../articles/article.entity';
import { User } from '../users/user.entity';

export enum Action {
  Manage = 'manage', // wildcard
  Create = 'create',
  Read = 'read',
  Update = 'update',
  Delete = 'delete',
}

type Subjects = InferSubjects<typeof Article | typeof User> | 'all';
export type AppAbility = MongoAbility<[Action, Subjects]>;

@Injectable()
export class AbilityFactory {
  createForUser(user: User): AppAbility {
    const { can, cannot, build } = new AbilityBuilder<AppAbility>(
      createMongoAbility,
    );

    if (user.role === 'admin') {
      can(Action.Manage, 'all'); // full access
    } else {
      can(Action.Read, Article);
      can(Action.Create, Article);
      // attribute condition: only your own articles
      can(Action.Update, Article, { authorId: user.id });
      can(Action.Delete, Article, { authorId: user.id });
      cannot(Action.Delete, Article, { published: true }).because(
        'Published articles cannot be deleted',
      );
    }

    return build({
      detectSubjectType: (item) =>
        item.constructor as ExtractSubjectType<Subjects>,
    });
  }
}

The @CheckPolicies decorator

Policies are expressed as small handler objects (or functions) that receive the ability and return a boolean. A custom decorator attaches them as route metadata so the guard can read them with the Reflector.

// casl/policies.decorator.ts
import { SetMetadata } from '@nestjs/common';
import { AppAbility } from './ability.factory';

export interface IPolicyHandler {
  handle(ability: AppAbility): boolean;
}
type PolicyHandlerCallback = (ability: AppAbility) => boolean;
export type PolicyHandler = IPolicyHandler | PolicyHandlerCallback;

export const CHECK_POLICIES_KEY = 'check_policy';
export const CheckPolicies = (...handlers: PolicyHandler[]) =>
  SetMetadata(CHECK_POLICIES_KEY, handlers);

Building the PoliciesGuard

The guard reads the handlers, builds the requesting user’s ability, and runs every handler. If any returns false, access is denied. Because abilities are per-request, this runs after your authentication guard has populated request.user.

// casl/policies.guard.ts
import {
  CanActivate,
  ExecutionContext,
  ForbiddenException,
  Injectable,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AbilityFactory, AppAbility } from './ability.factory';
import {
  CHECK_POLICIES_KEY,
  PolicyHandler,
} from './policies.decorator';

@Injectable()
export class PoliciesGuard implements CanActivate {
  constructor(
    private reflector: Reflector,
    private abilityFactory: AbilityFactory,
  ) {}

  canActivate(context: ExecutionContext): boolean {
    const handlers =
      this.reflector.get<PolicyHandler[]>(
        CHECK_POLICIES_KEY,
        context.getHandler(),
      ) ?? [];

    const { user } = context.switchToHttp().getRequest();
    const ability = this.abilityFactory.createForUser(user);

    const allowed = handlers.every((h) => this.exec(h, ability));
    if (!allowed) {
      throw new ForbiddenException('Insufficient permissions');
    }
    return true;
  }

  private exec(handler: PolicyHandler, ability: AppAbility): boolean {
    return typeof handler === 'function'
      ? handler(ability)
      : handler.handle(ability);
  }
}

The guard above only checks class-level abilities (e.g. “can read any article”). For instance-level checks — “can update this article” — the resource must be loaded first. Do that ownership check inside the service or with a dedicated handler that fetches the entity, since the guard runs before the route handler.

Enforcing policies on routes

Register the factory and apply the guard, then declare policies declaratively per route.

// articles/articles.controller.ts
import {
  Controller,
  Delete,
  Get,
  Param,
  UseGuards,
} from '@nestjs/common';
import { PoliciesGuard } from '../casl/policies.guard';
import { CheckPolicies } from '../casl/policies.decorator';
import { Action, AppAbility } from '../casl/ability.factory';
import { Article } from './article.entity';

@Controller('articles')
@UseGuards(PoliciesGuard)
export class ArticlesController {
  @Get()
  @CheckPolicies((ability: AppAbility) => ability.can(Action.Read, Article))
  findAll() {
    return [{ id: 1, title: 'Hello ABAC' }];
  }

  @Delete(':id')
  @CheckPolicies((ability: AppAbility) =>
    ability.can(Action.Delete, Article),
  )
  remove(@Param('id') id: string) {
    return { deleted: id };
  }
}

For instance-level ownership, fetch the entity in the handler and re-check with the loaded object:

// articles/articles.service.ts
import { ForbiddenException, Injectable } from '@nestjs/common';
import { AbilityFactory, Action } from '../casl/ability.factory';
import { User } from '../users/user.entity';
import { Article } from './article.entity';

@Injectable()
export class ArticlesService {
  constructor(private abilityFactory: AbilityFactory) {}

  update(user: User, article: Article, data: Partial<Article>) {
    const ability = this.abilityFactory.createForUser(user);
    if (ability.cannot(Action.Update, article)) {
      throw new ForbiddenException('You can only edit your own articles');
    }
    return Object.assign(article, data);
  }
}

Output:

GET /articles            → 200 [{ "id": 1, "title": "Hello ABAC" }]
DELETE /articles/9 (own) → 200 { "deleted": "9" }
DELETE /articles/9 (other user) → 403 { "message": "Insufficient permissions" }
PATCH another user's article → 403 "You can only edit your own articles"

Best Practices

  • Keep one AbilityFactory as the single source of truth; never scatter if (user.role === ...) checks across services.
  • Use the wildcard manage/all rule for admins, and define everything else explicitly with conditions.
  • Perform class-level checks in the guard, but do instance-level (ownership) checks where the resource is loaded — the guard cannot see entities that don’t exist yet.
  • Always quote rule failures with .because(...) so the UI can surface a meaningful reason.
  • Share the serialized ability (@casl/ability/extra’s packRules) with the frontend to keep client and server permissions in sync.
  • Cover abilities with unit tests — ability.can() is pure and trivially testable without spinning up the HTTP layer.
Last updated June 14, 2026
Was this helpful?