Skip to content
ExponaBLOG
NESTJS · DAY 024 / 100

Roles and permissions with decorators: a practical guide

Fine-grained authorization that reads like documentation.

Expona Admin8 min read

Roles and permissions with decorators

Authorization is where many teams stumble: they bolt it on late, tangle it with business logic, and end up redeploying whenever a role changes. Roles and permissions with decorators fix that by making authorization explicit, declarative, and testable. I'll show you how to build a system that reads like documentation and lets you change permissions without touching code.

Why decorators matter for authorization

In my early projects, I hardcoded permission checks inside route handlers. If a user tried to assign a lead, I'd load the user, check if they were an "admin" or "team_lead", then either proceed or throw an error. The problem: roles and permissions were invisible in the route signature, scattered across multiple handlers, and impossible to audit without reading every function body.

Decorators let you declare what a route needs, right at the route definition. When someone new reads your code, they see @Permissions('leads:assign') and instantly know what's required. Change the permission? Update the decorator, not ten different guard conditionals. This is how mature platforms handle it—Slack, Stripe, Figma all use permission-based gates, not role checks.

A metadata decorator — Attach required permissions to a route

Start by creating a simple decorator that attaches permission metadata to your handler:

typescript
import { SetMetadata } from '@nestjs/common';

export const Permissions = (...permissions: string[]) =>
  SetMetadata('permissions', permissions);

That's it. SetMetadata is NestJS's way of tagging a handler with arbitrary data. The format is simple: a string key and a value. I use permission strings like 'leads:assign', 'leads:delete', 'campaigns:edit'. The colon separates the resource from the action, making permissions readable and machine-parseable.

Now decorate your routes:

typescript
@Controller('leads')
export class LeadsController {
  constructor(private leadsService: LeadsService) {}

  @Permissions('leads:assign')
  @Post(':id/assign')
  async assign(
    @Param('id') leadId: string,
    @Body() body: AssignLeadDto,
    @Req() req: Request,
  ) {
    return this.leadsService.assign(leadId, body.userId);
  }

  @Permissions('leads:view')
  @Get()
  async list() {
    return this.leadsService.findAll();
  }

  @Permissions('leads:delete')
  @Delete(':id')
  async remove(@Param('id') leadId: string) {
    return this.leadsService.remove(leadId);
  }
}

Every decorated route now advertises exactly what permission is required. A route handler with no @Permissions decorator is public (or you can configure a default—I usually require at least one permission on all protected routes).

A guard that checks it — Read metadata with Reflector and compare against the user's permissions

The decorator alone does nothing. You need a guard that reads the metadata and validates the user's permissions. Here's a solid implementation:

typescript
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';

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

  canActivate(context: ExecutionContext): boolean {
    // Read the permissions metadata from the handler
    const requiredPermissions = this.reflector.get<string[]>(
      'permissions',
      context.getHandler(),
    );

    // If no permissions are required, allow access
    if (!requiredPermissions || requiredPermissions.length === 0) {
      return true;
    }

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

    // User must exist
    if (!user) {
      throw new ForbiddenException('User not authenticated');
    }

    // Check if user has any of the required permissions
    const hasPermission = requiredPermissions.some((perm) =>
      user.permissions.includes(perm),
    );

    if (!hasPermission) {
      throw new ForbiddenException(
        `Missing permissions: ${requiredPermissions.join(', ')}`,
      );
    }

    return true;
  }
}

Key points here:

  • Reflector reads metadata: this.reflector.get('permissions', context.getHandler()) pulls the permissions array from the @Permissions decorator.
  • Fail open if no permissions defined: Routes without decorators are accessible (adjust this based on your security posture).
  • User permissions are an array: In my systems, user.permissions is always a flat list of permission strings. No role lookups in the guard.
  • Use some(), not every(): If you decorate a route with multiple permissions, some() means the user needs at least one. If you want all permissions, use every().

Register the guard globally in your app module:

typescript
import { APP_GUARD } from '@nestjs/core';

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

Now every request flows through the guard. If the user lacks a required permission, they get a 403 Forbidden before your handler even runs.

Permissions over roles — Roles group permissions. Code checks permissions, so roles can change without deploys.

Here's the critical insight: your guard checks permissions, not roles. Roles are just containers. In my trading platform, I had roles like "Analyst", "Trader", "Risk Manager". Each role carried a set of permissions: 'trades:view', 'trades:execute', 'risk:approve'.

When a user logged in, I'd load their roles from the database, then expand them into a flat permission list:

typescript
// In your auth service, after validating the user
const user = await this.usersService.findById(userId);
const roles = await this.rolesService.findByIds(user.roleIds);

// Flatten all permissions from all roles
const permissions = Array.from(
  new Set(roles.flatMap((role) => role.permissions)),
);

// Store permissions on the user object (or in a JWT claim)
const token = this.jwtService.sign({
  sub: user.id,
  permissions,
});

Why is this better than checking roles directly? Because a role can be updated without redeploying. If I add 'trades:export' to the "Analyst" role in the database, any new login will grant that permission. If I had hardcoded role checks in my guards, I'd need a redeploy.

You can even let administrators manage permission-role mappings in a UI. The business doesn't need engineering for every authorization change.

Resource ownership — Some checks need the entity. Do them in the service after loading it.

Permissions are coarse. They answer "can this user do X?" But sometimes you need to ask "can this user do X to this specific resource?" That's where resource ownership enters.

Example: a user might have 'leads:edit', but only for leads assigned to their team. The decorator and guard can't know which team the lead belongs to until you load it. So defer ownership checks to the service:

typescript
@Controller('leads')
export class LeadsController {
  constructor(private leadsService: LeadsService) {}

  @Permissions('leads:edit')
  @Patch(':id')
  async update(
    @Param('id') leadId: string,
    @Body() updateDto: UpdateLeadDto,
    @Req() req: Request,
  ) {
    // Guard verified user has 'leads:edit'. Now check if they own this lead.
    return this.leadsService.update(leadId, updateDto, req.user);
  }
}

In the service:

typescript
@Injectable()
export class LeadsService {
  constructor(
    private db: DatabaseService,
    private auditService: AuditService,
  ) {}

  async update(leadId: string, updateDto: UpdateLeadDto, user: any) {
    const lead = await this.db.leads.findUnique({
      where: { id: leadId },
    });

    if (!lead) {
      throw new NotFoundException('Lead not found');
    }

    // Ownership check: user must be on the same team as the lead
    if (lead.teamId !== user.teamId) {
      throw new ForbiddenException(
        'You cannot edit leads outside your team',
      );
    }

    // Proceed with update
    const updated = await this.db.leads.update({
      where: { id: leadId },
      data: updateDto,
    });

    await this.auditService.log(user.id, 'lead:updated', leadId);
    return updated;
  }
}

This two-layer approach is clean: the guard checks global permissions, the service checks resource-level access. If you have complex ownership logic (e.g., "edit only if you created it OR you're a team lead"), it lives in the service where it belongs.

Common mistakes

  • Putting resource checks in the guard: The guard shouldn't load the entity—it slows down every request and makes guards do too much. Leave it to the service.
  • Hardcoding role names in guards: If you check if (user.role === 'admin'), you can't change roles without a redeploy. Always check permissions instead.
  • Forgetting to include the user's permissions in your authentication token: If permissions live only in the database and you don't cache them on the JWT, every request needs a database hit. Load permissions at login and embed them in the token.
  • Using roles and permissions interchangeably: They're different. Roles are labels; permissions are capabilities. Keep them separate in your code and database.
  • Not auditing permission checks: Log when a user is denied access. It helps you debug and detect unauthorized attempts. Use something like 'leads:edit:denied' as an audit event.

Key takeaways

  • Use decorators to declare required permissions on your routes. It makes authorization visible and auditable.
  • Build a PermissionsGuard that uses Reflector to read metadata and check user permissions before the handler runs.
  • Check permissions in your guards, not roles. Roles are just containers; they can change without a redeploy if permissions are decoupled.
  • Resource-level checks (ownership, team membership) belong in the service, not the guard. Guards are for global access control; services are for entity-specific rules.
  • Embed permissions in your JWT or session cache. Don't hit the database on every request to verify them.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles