Skip to content

Role-Based Access Control (RBAC) in a SaaS

Aman Kumar Singh8 min read
Part 13 of 40From the Full Stack SaaS Masterclass series
Role-Based Access Control (RBAC) in a SaaS — article by Aman Kumar Singh

In Refresh Tokens Done Right we got a user safely authenticated and kept them signed in without leaking long-lived credentials. Authentication answers "who is this." It says nothing about what they're allowed to touch once they're inside your app. That's the next problem, and it's the one that quietly determines whether a customer trusts you with their data.

This is part of the Full Stack SaaS Masterclass, a build-it-for-real series taking a multi-tenant SaaS from an empty folder to production. Module 2 is authentication and authorization, and role-based access control is where authorization stops being a single isAdmin boolean and starts looking like a real system.

I want to be upfront about scope here. RBAC is a big topic and it's easy to over-engineer on day one. The goal of this article is to build the version that earns its complexity: enough structure to support a team-based SaaS with owners, admins, and members, without reaching for a permissions engine you don't need yet.

Why "just check the role" stops working

Every SaaS starts the same way. You add a role column to the user table, values like owner, admin, member, and you sprinkle if (user.role === 'admin') through your controllers. It works, and for a while it's genuinely fine.

The trouble shows up in a predictable order. First, a customer asks for a role between admin and member: someone who can invite teammates but not touch billing. Now you're adding an enum value and hunting down every if statement that checks role equality. Second, you go multi-tenant and realize a single user can belong to multiple organizations with a different role in each. A role column on the user table can't represent that; it has to live on the membership, not the person. Third, someone asks "can user X do Y" from a support ticket, and you have no way to answer without reading code.

The fix is to separate three things that a scattered role check conflates: who the user is, what role they hold in a given organization, and what permissions that role actually grants. Once those are separate, adding a role, changing what a role can do, or answering "why can't this user see that page" all become data questions instead of code archaeology.

Modeling roles and permissions in PostgreSQL

For a typical SaaS, four tables cover this cleanly: roles, permissions, a join table between them, and a membership table that ties a user to an organization with a role.

create table roles (
  id uuid primary key default gen_random_uuid(),
  organization_id uuid references organizations(id), -- null for system-defined roles
  name text not null,
  is_system boolean not null default false,
  created_at timestamptz not null default now(),
  unique (organization_id, name)
);

create table permissions (
  id uuid primary key default gen_random_uuid(),
  key text unique not null, -- e.g. 'members:invite', 'billing:manage'
  description text
);

create table role_permissions (
  role_id uuid references roles(id) on delete cascade,
  permission_id uuid references permissions(id) on delete cascade,
  primary key (role_id, permission_id)
);

create table organization_memberships (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references users(id) on delete cascade,
  organization_id uuid references organizations(id) on delete cascade,
  role_id uuid references roles(id) not null,
  created_at timestamptz not null default now(),
  unique (user_id, organization_id)
);

A few decisions here matter more than they look. Permissions are strings like members:invite, not booleans on the role table, because the set of permissions grows over time and you don't want a schema migration every time you add one. organization_id on roles is nullable so you can seed a fixed set of system roles (owner, admin, member) shared across all tenants, while still allowing an organization to define custom roles later if your product needs that. The unique constraint on organization_memberships enforces one role per user per organization, which matches how most B2B SaaS products actually work: a user's role at Acme Corp has nothing to do with their role at a different customer.

Seed the system roles and their permissions once, at migration time, not in application code:

insert into permissions (key, description) values
  ('members:invite', 'Invite new members to the organization'),
  ('members:remove', 'Remove members from the organization'),
  ('billing:manage', 'View and change billing and subscription'),
  ('projects:write', 'Create and edit projects'),
  ('projects:delete', 'Delete projects');

-- 'owner' role gets everything; 'admin' gets everything except billing;
-- 'member' gets projects:write only. Wire these up via role_permissions.

Enforcing RBAC in NestJS

The schema is only half the job. The other half is making sure every route actually checks permissions, and that the check is declarative enough that a reviewer can see it without reading the handler body.

The pattern I use is a custom decorator plus a guard, similar to how NestJS's own @Roles() examples work but keyed on permissions rather than role names, so the check stays stable even if you rename or restructure roles later.

// permissions.decorator.ts
import { SetMetadata } from '@nestjs/common';

export const PERMISSIONS_KEY = 'permissions';
export const RequirePermissions = (...permissions: string[]) =>
  SetMetadata(PERMISSIONS_KEY, permissions);
// permissions.guard.ts
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { PERMISSIONS_KEY } from './permissions.decorator';
import { AuthorizationService } from './authorization.service';

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

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const required = this.reflector.getAllAndOverride<string[]>(PERMISSIONS_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    if (!required || required.length === 0) return true;

    const request = context.switchToHttp().getRequest();
    const { user, params } = request;
    const organizationId = params.organizationId ?? request.organizationId;

    const granted = await this.authz.getPermissions(user.id, organizationId);
    const hasAll = required.every((permission) => granted.has(permission));

    if (!hasAll) {
      throw new ForbiddenException('You do not have permission to perform this action');
    }
    return true;
  }
}
// authorization.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRedis } from '@nestjs-modules/ioredis';
import Redis from 'ioredis';
import { PrismaService } from '../prisma/prisma.service';

@Injectable()
export class AuthorizationService {
  constructor(private prisma: PrismaService, @InjectRedis() private redis: Redis) {}

  async getPermissions(userId: string, organizationId: string): Promise<Set<string>> {
    const cacheKey = `perms:${organizationId}:${userId}`;
    const cached = await this.redis.get(cacheKey);
    if (cached) return new Set(JSON.parse(cached));

    const membership = await this.prisma.organizationMembership.findUnique({
      where: { userId_organizationId: { userId, organizationId } },
      include: { role: { include: { rolePermissions: { include: { permission: true } } } } },
    });

    const permissions = membership?.role.rolePermissions.map((rp) => rp.permission.key) ?? [];
    await this.redis.set(cacheKey, JSON.stringify(permissions), 'EX', 300);
    return new Set(permissions);
  }

  async invalidate(userId: string, organizationId: string): Promise<void> {
    await this.redis.del(`perms:${organizationId}:${userId}`);
  }
}

Using it in a controller reads like documentation:

@UseGuards(JwtAuthGuard, PermissionsGuard)
@RequirePermissions('members:invite')
@Post('organizations/:organizationId/members')
inviteMember(@Param('organizationId') organizationId: string, @Body() dto: InviteMemberDto) {
  return this.membersService.invite(organizationId, dto);
}

The Redis layer exists because a permissions lookup touching three tables on every single request adds real latency to your hottest code path, and permissions change far less often than they're read. Call invalidate() whenever a membership's role changes or a role's permissions are edited, and set a short TTL as a backstop for anything you miss. Don't add this cache on day one if your traffic doesn't warrant it; a direct query with a proper index on organization_memberships is completely fine until you have a reason to believe otherwise.

Tradeoffs: RBAC versus finer-grained models

RBAC groups permissions into roles, which is exactly its strength and its limit. It reads naturally. It's easy to explain to a customer's IT admin, and it maps cleanly onto how most organizations already think about access: owners, admins, members. For the large majority of SaaS products, that's enough.

Where it strains is resource-level rules that don't fit a role: "editors can edit any project, except the ones marked confidential" or "a user can only see invoices for their own department." That's attribute-based access control (ABAC) territory, where the decision depends on attributes of the resource and the request, not just the caller's role. Libraries like CASL exist precisely for this, letting you express conditions alongside permissions. My advice is to resist reaching for ABAC until a real requirement forces it. It's a genuinely more powerful model. It's also harder to reason about, harder to test exhaustively, and harder to explain when a customer asks why they can't see something. Add a resource_id scoping column to a permission check before you add a full conditional rules engine; that covers a surprising number of "almost ABAC" requirements on its own.

The other real tradeoff is role granularity. Every custom role you let a customer define is a support burden: they will create overlapping roles, forget what one does, and file a ticket asking why a permission is missing. Ship the fixed system roles first, and only add custom roles once paying customers are explicitly asking for them.

Production pitfalls

Tenant leakage is the one that matters most. Every permission check has to be scoped to the organization in the request, not just the user. A user who is an admin at Organization A must never pass a permission check for a resource belonging to Organization B, even if the route only checks user.permissions.has('projects:delete') without also checking resource.organizationId === request.organizationId. Treat this as a security bug, not an edge case.

Stale caches after a role change are the second most common issue. If you revoke someone's admin role but their permissions were cached for five minutes, they keep admin-level access for up to five minutes. For most actions that's an acceptable window. For anything sensitive, security settings, billing, deleting the organization, invalidate the cache synchronously as part of the role-change transaction rather than relying on the TTL alone.

Watch for role sprawl inside your own codebase, not just customer-facing roles. It's tempting to add an internal-only role for a one-off admin panel feature. Six months later nobody remembers what half the roles do or which ones are actually load-bearing. Keep a single source of truth, ideally the seed migration plus a short doc, that lists every role and exactly what it grants.

Finally, don't forget the audit trail. The moment a customer asks "who changed this user's role and when," you want an answer in a query, not a Slack thread. A simple role_change_log table with actor, target, old role, new role, and timestamp costs little to add now and is painful to reconstruct later.

Key takeaways

  • Separate identity, membership, and role from permissions. A role table plus a permissions join table gives you a system you can query and reason about, not one you have to grep for.
  • Scope roles to the organization membership, not the user record, so one person can hold different roles across different tenants.
  • Enforce permissions declaratively with a guard and decorator in NestJS, keyed on permission strings rather than role names, so renaming a role doesn't break every check.
  • Cache permission lookups in Redis only once the query cost is real, and always invalidate on role or membership change rather than trusting the TTL alone for sensitive actions.
  • Reach for ABAC or a rules engine like CASL only when a genuine resource-level requirement demands it; most SaaS products never outgrow well-scoped RBAC.
  • Every permission check must also validate tenant ownership of the resource. A correct role check on the wrong tenant's data is still a data leak.

Frequently asked questions

What is role-based access control in simple terms?

RBAC is an authorization model where permissions are granted to roles rather than directly to users, and users are assigned one or more roles. Instead of checking "can this specific user do X," the system checks "does this user's role include permission X," which keeps access rules manageable as your user base grows.

Should I store roles on the user table or on a membership table?

Store roles on a membership table that links a user to an organization, not on the user record directly. In a multi-tenant SaaS, the same person can be an owner in one organization and a plain member in another, and a role column on the user table can't represent that.

Is RBAC enough, or do I need ABAC (attribute-based access control)?

RBAC covers the majority of SaaS authorization needs: it's simple, auditable, and matches how customers already think about roles. Move to ABAC only when you have concrete resource-level rules that a role can't express, since ABAC trades that flexibility for meaningfully more complexity to build, test, and explain.

How should I handle permission checks for API routes in NestJS?

Use a custom decorator that attaches required permissions to route metadata, then a guard that reads that metadata via `Reflector` and checks it against the current user's permissions for the request's organization. This keeps the check visible on the route definition instead of buried in a service method.

How long should I cache a user's permissions in Redis?

A few minutes is a reasonable default for most actions, since permission changes are relatively rare compared to permission checks. For sensitive operations like billing or account deletion, invalidate the cache synchronously when a role changes rather than relying purely on the TTL.

What's the biggest security mistake teams make implementing RBAC?

Checking the permission but not the tenant. A guard that confirms "this user has the `projects:delete` permission" without also confirming the target project belongs to that user's organization will happily let one tenant delete another tenant's data. Every authorization check in a multi-tenant system needs both parts.

Related articles

Role and Permission Authorization — Aman Kumar Singh
Audit Logs You Can Trust — Aman Kumar Singh
Organization and Multi-Tenant Authentication — 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.