Role and Permission Authorization
- nestjs
- nodejs
- typescript
- backend
- authorization
- rbac
- redis
- postgresql
The previous article covered Authentication with Passport and JWT: verifying who a user is and issuing a token that proves it. That article's FAQ made a promise it's time to keep. Permissions tend to change more often than roles, so this article covers resolving them from the database or a cache at request time, rather than baking them into the JWT.
This is part of the NestJS Production Guide, picking up right where the guards article left off. RolesGuard checking a coarse role list against a route is a fine starting point, and for a lot of applications it's genuinely enough. This article covers the point where that stops being true, and what a permission-based system looks like once you get there.
I want to be upfront about the tradeoff before writing a line of code: a fine-grained permission system is more flexible and more work to build, test, and reason about than roles alone. Reach for it when the roles model is visibly straining, not because it looks more sophisticated on a whiteboard.
Why roles alone eventually break down
A role is a label. billing_admin, owner, viewer. It's cheap to check and easy to reason about, and for most of an application's early life it maps cleanly onto what users are actually allowed to do. The trouble starts when two roles need almost the same capabilities but not quite, or when a customer asks for a custom role that can approve invoices but not delete them.
At that point, teams usually do one of two things. They either multiply roles, billing_admin, billing_admin_readonly, billing_admin_no_delete, until the role list is an unmanageable combinatorial mess, or they start writing if (user.role === 'owner' || user.role === 'billing_admin') checks scattered through controllers and services. Both paths get worse the longer they run.
Permissions solve this by decoupling "what can this user do" from "what is this user called." A role becomes a named bundle of permissions rather than a hardcoded set of if branches. billing_admin might grant invoice:read, invoice:approve, and invoice:export, while a custom billing_reviewer role grants only invoice:read and invoice:approve. The application code checks for the permission, invoice:approve, never for the role name. Which roles happen to carry that permission becomes a data problem. You add a row to a table instead of shipping a code change and a deploy.
This is also where I'd push back on reaching for a full policy engine, OPA, Casbin, or a custom rules DSL, before you've actually outgrown roles-plus-permissions. Those tools solve real problems around externalized, auditable policy, but they add operational surface: another service to run, a new language to learn, and one more thing that can be misconfigured. A permissions table and a guard that checks it will cover the large majority of SaaS authorization needs. Deferring the heavier tool until the simpler one is visibly insufficient is usually the right call.
Modeling roles and permissions
The schema itself is a well-worn shape: users have roles, roles have permissions, and the join happens at both levels so a permission can belong to more than one role.
-- migrations/1700000000000-CreateRbacTables.sql
CREATE TABLE permissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key TEXT NOT NULL UNIQUE, -- e.g. 'invoice:approve'
description TEXT
);
CREATE TABLE roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID REFERENCES organizations(id), -- NULL for system-wide roles
name TEXT NOT NULL,
UNIQUE (organization_id, name)
);
CREATE TABLE role_permissions (
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
permission_id UUID NOT NULL REFERENCES permissions(id) ON DELETE CASCADE,
PRIMARY KEY (role_id, permission_id)
);
CREATE TABLE user_roles (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, role_id)
);
Two decisions here are worth calling out. First, organization_id on roles being nullable lets you support both system-defined roles shared across every tenant and per-organization custom roles, without two separate tables. Second, permission keys are strings like invoice:approve rather than an enum baked into application code. Enums are faster to check and type-safe, which matters. But a string key backed by a database table turns a new permission into a migration and a row insert, instead of a code change and a redeploy. For a permission set that's still evolving, that flexibility is worth the small runtime cost of a string comparison.
A PermissionsService resolves the effective set for a user by walking the joins:
// src/modules/authorization/permissions.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserRole } from './entities/user-role.entity';
@Injectable()
export class PermissionsService {
constructor(
@InjectRepository(UserRole)
private readonly userRolesRepository: Repository<UserRole>,
) {}
async getEffectivePermissions(userId: string): Promise<Set<string>> {
const userRoles = await this.userRolesRepository.find({
where: { userId },
relations: ['role', 'role.permissions'],
});
const permissions = new Set<string>();
for (const userRole of userRoles) {
for (const permission of userRole.role.permissions) {
permissions.add(permission.key);
}
}
return permissions;
}
}
A Set here isn't an aesthetic choice. A user with two roles that both grant invoice:read shouldn't end up with a duplicate entry, and checking membership in a Set is a constant-time operation rather than an array scan, which matters once a permission list grows past a handful of entries.
Building the permissions guard and decorator
The guard shape mirrors the RolesGuard from the previous article, just checking permission keys instead of role names, and it reads the required permissions off a custom decorator via Reflector.
// src/common/decorators/require-permissions.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const PERMISSIONS_KEY = 'permissions';
export const RequirePermissions = (...permissions: string[]) =>
SetMetadata(PERMISSIONS_KEY, permissions);
// src/common/guards/permissions.guard.ts
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { PERMISSIONS_KEY } from '../decorators/require-permissions.decorator';
import { PermissionsService } from '../../modules/authorization/permissions.service';
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly permissionsService: PermissionsService,
) {}
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 = request.user;
if (!user) {
return false;
}
const granted = await this.permissionsService.getEffectivePermissions(user.userId);
const hasAll = required.every((permission) => granted.has(permission));
if (!hasAll) {
throw new ForbiddenException('Missing required permission');
}
return true;
}
}
Applying it on a controller reads naturally once the pieces are in place:
// src/modules/billing/billing.controller.ts
import { Controller, Post, Param, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { PermissionsGuard } from '../../common/guards/permissions.guard';
import { RequirePermissions } from '../../common/decorators/require-permissions.decorator';
import { BillingService } from './billing.service';
@Controller('billing')
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class BillingController {
constructor(private readonly billingService: BillingService) {}
@Post(':orgId/invoices/:invoiceId/approve')
@RequirePermissions('invoice:approve')
approve(@Param('orgId') orgId: string, @Param('invoiceId') invoiceId: string) {
return this.billingService.approveInvoice(orgId, invoiceId);
}
}
Notice JwtAuthGuard still runs first, exactly like in the roles guard, because PermissionsGuard depends on request.user already being populated. The ordering rule from the previous article carries over unchanged; only the check itself has gotten more granular.
Caching the permission lookup
getEffectivePermissions hits the database on every single request that passes through PermissionsGuard. Permission checks tend to sit on the hot path: every authenticated write, and plenty of reads. That's worth caching, and Redis is a natural fit since permission sets change far less often than they're read.
// src/modules/authorization/permissions.service.ts (with caching)
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import Redis from 'ioredis';
import { UserRole } from './entities/user-role.entity';
@Injectable()
export class PermissionsService {
private readonly ttlSeconds = 300;
constructor(
@InjectRepository(UserRole)
private readonly userRolesRepository: Repository<UserRole>,
private readonly redis: Redis,
) {}
async getEffectivePermissions(userId: string): Promise<Set<string>> {
const cacheKey = `permissions:${userId}`;
const cached = await this.redis.get(cacheKey);
if (cached) {
return new Set(JSON.parse(cached));
}
const permissions = await this.loadFromDatabase(userId);
await this.redis.set(cacheKey, JSON.stringify([...permissions]), 'EX', this.ttlSeconds);
return permissions;
}
async invalidate(userId: string): Promise<void> {
await this.redis.del(`permissions:${userId}`);
}
private async loadFromDatabase(userId: string): Promise<Set<string>> {
const userRoles = await this.userRolesRepository.find({
where: { userId },
relations: ['role', 'role.permissions'],
});
const permissions = new Set<string>();
for (const userRole of userRoles) {
for (const permission of userRole.role.permissions) {
permissions.add(permission.key);
}
}
return permissions;
}
}
A five-minute TTL is a reasonable starting point: short enough that a permission change propagates quickly, long enough to remove the database round trip from the vast majority of requests. The invalidate method matters as much as the caching itself. Anywhere a role assignment changes, adding a role to a user, editing what a role grants, that call site needs to invalidate the affected users' cache entries, or they'll keep operating on stale permissions until the TTL expires. This is the same staleness-versus-cost tradeoff every cache introduces, and the honest answer is that a short TTL plus explicit invalidation on writes covers the common case without needing a pub/sub invalidation scheme.
Production pitfalls
The first is permission drift between the database and the code. If invoice:approve is checked in a decorator but never actually inserted into the permissions table, or the seed data falls out of sync with what the code expects, the failure mode is a silent, permanent 403 for every role that should have had it. Treat the permission catalog as part of the schema, not as an afterthought, and seed it through migrations rather than ad hoc scripts.
The second is caching a permission set for a user whose role just changed and forgetting to invalidate. This is worse than it sounds for security-sensitive changes: revoking invoice:approve from a compromised or off-boarded user doesn't take effect until the cache entry expires, unless the revocation flow explicitly calls invalidate. Anywhere permissions change through an admin action, the invalidation call belongs in the same transaction or the same request handler. Treat it as part of the change itself, not a follow-up task someone might forget later.
The third is conflating a permission check with a tenant-scope check, the same trap covered for role guards in the previous article, and it doesn't go away just because the check got more granular. invoice:approve tells you a user can approve invoices somewhere; it says nothing about whether the specific invoice in the request belongs to their organization. The permission check and the tenant-scoping check are two different questions, and both need to happen.
The fourth is over-modeling permissions too early. A permission per button, per field, per minor variation of an action produces a catalog that's hard to audit and hard to reason about, and most of those permissions never actually diverge from each other in practice. Start with permissions that map to real, distinct business actions, invoice:approve, invoice:export, invoice:delete, and only split further when a real customer requirement demands it.
Key takeaways
- Roles are a fine starting point, but they conflate identity with capability. Permissions decouple "what a user is called" from "what they can do," which matters once custom or overlapping roles show up.
- Model permissions as data, a permissions table and role-permission joins, not as an enum baked into code, so adding one is a migration rather than a deploy.
- Keep the permissions guard's job narrow: check a `Set` of granted permission keys against what the route requires, after an authentication guard has already populated `request.user`.
- Cache the resolved permission set in Redis with a short TTL, and treat explicit invalidation on every role or permission change as non-negotiable, not optional cleanup.
- A permission check answers "can this user do this kind of thing," not "does this specific resource belong to them." Keep tenant-scoping checks separate and don't skip them.
- Reach for a full policy engine only once a permissions table and a guard have visibly stopped being enough; most SaaS authorization needs don't get there.
Frequently asked questions
What's the actual difference between RBAC and a permission-based system?
Role-based access control checks a role name directly against what a route requires. A permission-based system adds a layer underneath: roles are bundles of permissions, and the code checks the permission, not the role. RBAC alone is simpler and fine for a small, stable set of roles; permissions pay off once roles start needing fine-grained or custom variations.
Should permission checks happen in a guard or in the service layer?
Route-level permission requirements, "does this endpoint require `invoice:approve`," belong in a guard, because a guard can reject the request before the handler runs. Anything that depends on data the service already has loaded, like whether this specific invoice belongs to the caller's organization, is often clearer and cheaper to check inside the service.
Do I need a permissions table if my app only has three roles?
Probably not yet. Three stable roles with clearly distinct capabilities is exactly the case where a `RolesGuard` checking role names is the right amount of complexity. Introduce permissions when roles start needing overlapping or customer-specific variations that a fixed role list can't express cleanly.
How do I avoid stale permissions after caching them in Redis?
Keep the TTL short, minutes rather than hours, and call an explicit invalidation method anywhere a role or its permissions change: adding a role to a user, editing what a role grants, deleting a role. Relying on TTL expiration alone as the only invalidation path leaves a window where revoked access is still effectively granted.
Can a user have multiple roles, and does that complicate permission checks?
Yes, and it doesn't complicate the check itself if the effective permission set is a union of every role's permissions, which is what the `Set` in `getEffectivePermissions` gives you. It does mean removing a permission from one role isn't enough if the user holds a second role that also grants it, which is worth remembering during a security review.
Is it worth adopting a policy engine like Casbin or OPA instead of building this myself?
Those tools solve real problems around externalized, auditable policy and complex condition logic, but they add a service or a rules language to operate and learn. A permissions table and a guard cover the common SaaS case: role-based grants with occasional custom roles. Move to a policy engine when you have concrete requirements, like conditions based on resource attributes at evaluation time, that outgrow a straightforward permission lookup.
Explore more on

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.