Back to blog
permissions·7 min read

Role-Based Access Control in NestJS (RBAC with Roles and Permissions)

A complete walkthrough of building role-based access control in NestJS — design the schema, write permission checks, protect routes with guards, and avoid the pitfalls that bite teams in production.

Khatab Wedaa

Khatab Wedaa

Software Engineer · Nestbolt

Role-Based Access Control in NestJS (RBAC with Roles and Permissions)

Authentication tells you who a request is from. Authorization tells you what they're allowed to do. Most NestJS tutorials cover JWT and stop there — they leave the second half as an exercise, and teams end up scattering if (user.role === "admin") checks across their controllers. That works for a demo. It does not work once you have ten roles, fifty permissions, and a customer asking why a "Manager" can still delete other people's invoices.

This guide walks through building proper role-based access control (RBAC) in a NestJS application — the schema design, the permission check API, route guards, and the pitfalls that turn a clean RBAC layer into a graveyard of one-off isAdmin flags. We'll use TypeORM for persistence, but the patterns translate directly to Prisma, Mongoose, or anything else.

What you'll build

By the end of this tutorial, your NestJS app will support:

  • users, roles, and permissions tables with the right join tables between them
  • A userHasPermissionTo(user, "posts.delete") API that resolves both direct permissions and permissions inherited via roles
  • @RequireRoles("admin") and @RequirePermissions("posts.delete") decorators with matching guards
  • Per-tenant or per-organization scoping if you need it later
  • A pattern for syncing your permission catalogue at app boot so new permissions show up automatically

Prerequisites

  • Node.js 18 or later
  • A NestJS 10+ project with TypeORM configured
  • An existing authentication layer that puts the user on the request — the JWT auth guide covers this if you don't have one yet

1. Design the schema

The classic RBAC schema has five tables. Three for the core entities, two for the many-to-many joins:

users               roles               permissions
  id                  id                  id
  email               name                name
  ...                 ...                 ...
 
users_roles         roles_permissions   users_permissions
  user_id             role_id             user_id
  role_id             permission_id       permission_id

Two important details:

Permissions belong to roles, but they can also be granted directly to users. This is what lets you say "this user is a Manager, plus has the billing.export permission specifically because they handle invoicing." Without the direct join, you end up creating a new role for every one-off exception.

Permissions are named, not enumerated. Use strings like "posts.create", "posts.delete", "billing.export" — never integers or enums. Permission names are application data, not code constants. New permissions show up when you ship a new feature, and you want that to be a database insert, not a TypeScript release.

Here are the entities. We'll keep them small and add only what we need:

// src/permissions/entities/permission.entity.ts
import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  ManyToMany,
  Unique,
} from "typeorm";
import { Role } from "./role.entity";
 
@Entity("permissions")
@Unique(["name"])
export class Permission {
  @PrimaryGeneratedColumn("uuid")
  id!: string;
 
  @Column()
  name!: string;
 
  @ManyToMany(() => Role, (role) => role.permissions)
  roles!: Role[];
}
// src/permissions/entities/role.entity.ts
import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  ManyToMany,
  JoinTable,
  Unique,
} from "typeorm";
import { Permission } from "./permission.entity";
 
@Entity("roles")
@Unique(["name"])
export class Role {
  @PrimaryGeneratedColumn("uuid")
  id!: string;
 
  @Column()
  name!: string;
 
  @ManyToMany(() => Permission, (permission) => permission.roles, {
    cascade: false,
  })
  @JoinTable({
    name: "roles_permissions",
    joinColumn: { name: "role_id" },
    inverseJoinColumn: { name: "permission_id" },
  })
  permissions!: Permission[];
}

The User entity adds two many-to-many joins — one to roles, one directly to permissions:

// src/users/user.entity.ts
@Entity("users")
export class User {
  @PrimaryGeneratedColumn("uuid")
  id!: string;
 
  @Column({ unique: true })
  email!: string;
 
  @ManyToMany(() => Role)
  @JoinTable({
    name: "users_roles",
    joinColumn: { name: "user_id" },
    inverseJoinColumn: { name: "role_id" },
  })
  roles!: Role[];
 
  @ManyToMany(() => Permission)
  @JoinTable({
    name: "users_permissions",
    joinColumn: { name: "user_id" },
    inverseJoinColumn: { name: "permission_id" },
  })
  permissions!: Permission[];
}

2. Write the permission check

The single function that matters is userHasPermissionTo(user, name). It needs to return true if the user has the permission directly, or via any role they belong to.

// src/permissions/permission.service.ts
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { User } from "../users/user.entity";
 
@Injectable()
export class PermissionService {
  constructor(
    @InjectRepository(User) private readonly userRepo: Repository<User>,
  ) {}
 
  async userHasPermissionTo(userId: string, name: string): Promise<boolean> {
    const user = await this.userRepo.findOne({
      where: { id: userId },
      relations: ["roles", "roles.permissions", "permissions"],
    });
    if (!user) return false;
 
    if (user.permissions?.some((p) => p.name === name)) return true;
    return user.roles?.some((r) =>
      r.permissions?.some((p) => p.name === name),
    );
  }
 
  async userHasAnyPermission(
    userId: string,
    names: string[],
  ): Promise<boolean> {
    for (const name of names) {
      if (await this.userHasPermissionTo(userId, name)) return true;
    }
    return false;
  }
}

This is correct but loads three relations on every check. In production you'll want to either:

  • Cache the user's resolved permission set in Redis for the duration of the request
  • Materialize a user_permissions_view once per session and read from it

Don't hand-write the cache layer until you measure the cost — premature caching is how RBAC bugs slip in.

3. Build the guards and decorators

NestJS gives you Reflector to read decorator metadata inside a guard. Pair a SetMetadata decorator with a guard, and you get clean route-level checks.

// src/permissions/decorators.ts
import { SetMetadata } from "@nestjs/common";
 
export const PERMISSIONS_KEY = "permissions";
export const RequirePermissions = (...permissions: string[]) =>
  SetMetadata(PERMISSIONS_KEY, permissions);
 
export const ROLES_KEY = "roles";
export const RequireRoles = (...roles: string[]) =>
  SetMetadata(ROLES_KEY, roles);

The permission guard:

// src/permissions/permissions.guard.ts
import {
  CanActivate,
  ExecutionContext,
  Injectable,
  ForbiddenException,
} from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { PermissionService } from "./permission.service";
import { PERMISSIONS_KEY } from "./decorators";
 
@Injectable()
export class PermissionsGuard implements CanActivate {
  constructor(
    private readonly reflector: Reflector,
    private readonly permissions: PermissionService,
  ) {}
 
  async canActivate(ctx: ExecutionContext): Promise<boolean> {
    const required = this.reflector.getAllAndOverride<string[]>(
      PERMISSIONS_KEY,
      [ctx.getHandler(), ctx.getClass()],
    );
    if (!required?.length) return true;
 
    const { user } = ctx.switchToHttp().getRequest();
    if (!user) throw new ForbiddenException();
 
    const ok = await this.permissions.userHasAnyPermission(user.id, required);
    if (!ok) throw new ForbiddenException();
    return true;
  }
}

Use it on a controller:

@Controller("posts")
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class PostsController {
  @Delete(":id")
  @RequirePermissions("posts.delete")
  remove(@Param("id") id: string) {
    return this.postsService.remove(id);
  }
}

The order of guards matters — always put the auth guard before the permission guard. The permission guard expects req.user to already be populated. If you reverse them, the permission check will throw on every unauthenticated request before NestJS gets a chance to return a clean 401.

4. Sync the permission catalogue at boot

A subtle but important pattern: your code declares which permissions exist ("posts.create", "posts.delete"), but the database is the source of truth. If a developer ships a feature that calls userHasPermissionTo(user, "posts.publish") before that permission row exists, every check returns false silently. Nothing breaks loudly — users just get 403s on a feature that should work.

The fix is to sync the catalogue on app boot:

// src/permissions/permission-sync.service.ts
@Injectable()
export class PermissionSyncService implements OnApplicationBootstrap {
  constructor(
    @InjectRepository(Permission)
    private readonly repo: Repository<Permission>,
  ) {}
 
  async onApplicationBootstrap() {
    const declared = [
      "posts.create",
      "posts.update",
      "posts.delete",
      "posts.publish",
      "billing.export",
      // ... declared in code, the source list
    ];
 
    for (const name of declared) {
      await this.repo
        .createQueryBuilder()
        .insert()
        .values({ name })
        .orIgnore()
        .execute();
    }
  }
}

Now adding a new permission is two lines: append to the array, deploy. The row gets inserted on first boot, and admins can assign it to roles through the UI.

5. Common pitfalls

PitfallWhat goes wrongFix
Hardcoded if (user.role === "admin") checksA new role like superadmin doesn't inherit accessAlways check permissions, never roles, in business logic
Auth guard registered after permission guardUnauthenticated requests throw 403 instead of 401Order guards: auth first, permissions second
Wildcard permissions like "posts.*"Easy to grant, painful to auditSpell out each action; a long list is honest
Permissions cached forever in RedisRevoked permissions still work for hoursCache per-request, or invalidate on role/permission changes
Loading user.roles.permissions on every requestN+1 queries on hot endpointsEager-load once in the auth guard, attach to req.user
Permissions enum in TypeScriptAdding a permission requires a releasePermissions are data — keep them in the DB and seed via boot sync

Going further: @nestbolt/permissions

Hand-rolling RBAC works, but you'll write the same five tables, the same userHasPermissionTo, the same two guards, and the same boot-time sync in every project. @nestbolt/permissions is the version of this code we extracted after writing it for the fourth time.

It ships:

  • PermissionsModule.forRoot({ userRepository }) — point it at your existing user repository, no schema migration needed for User
  • A PermissionUserRepository interface — implement it once on your UsersService and the module wires up the rest
  • RoleService.findOrCreate(), RoleService.givePermissionTo(), PermissionService.findOrCreate()
  • PermissionRegistrarService.assignRole(), syncRoles(), userHasPermissionTo()
  • @RequireRoles, @RequirePermissions, @RequireRolesOrPermissions decorators with matching RolesGuard, PermissionsGuard, RolesOrPermissionsGuard
  • Permission catalogue sync on boot — exactly the pattern from step 4

Setup is three steps: register the module, implement the repository interface, apply the guards. The full API is in the permissions quick-start.

If you're building a project from scratch, drop it in. If you already have RBAC in place, the patterns in this post are still the right shape — @nestbolt/permissions just saves you from re-writing them.

Wrapping up

RBAC is one of those features that feels simple until it isn't. The schema above is small, but it scales: you can layer in tenant scoping by adding a tenant_id to users_roles, you can add permission groups by introducing a parent-child relation on permissions, and you can swap the in-memory check for a cached one without changing any controller code. The trick is to keep the surface narrow — one userHasPermissionTo function, one decorator per dimension, one guard per dimension — and let the database carry the data.

If this guide saved you an afternoon, star the repo — it helps other backend engineers find the package. And if you'd like to see a follow-up on multi-tenant RBAC or row-level permission checks, open an issue on GitHub.

Khatab Wedaa

Written by Khatab Wedaa

Software Engineer · Nestbolt

Building open-source NestJS packages — authentication, permissions, audit logs, media uploads, and the patterns every backend ends up rebuilding.