Authorization is the part of a backend everyone underestimates. Authentication gets all the attention — tokens, hashing, OAuth — but "who are you" is genuinely the easy question. The hard one is "what are you allowed to do", and it's hard because the answer keeps changing as the product grows. Every codebase I've inherited had authorization bolted on in whatever shape the first feature demanded, and every one of them was paying interest on that decision years later.
The two models you'll actually choose between are RBAC — role-based access control — and ABAC — attribute-based access control. Most teams start with RBAC because it maps to how humans talk ("she's an admin"), and most teams eventually graft ABAC-ish rules onto it because pure roles can't answer questions like "can this user edit this specific document". This post walks through both, where the first one breaks, and how I wire the whole thing into NestJS guards without scattering if (user.role === 'admin') across forty controllers.
RBAC: where everyone starts
RBAC is three concepts: users have roles, roles imply permissions, endpoints require permissions. In its simplest (and most common) degenerate form, the middle layer disappears and endpoints check roles directly:
@Post('posts')
@Roles('admin', 'editor')
createPost(@Body() dto: CreatePostDto) { ... }
This is fine at the scale where it's usually written: three roles, a dozen endpoints, one developer who holds the whole permission matrix in their head. The @Roles() decorator plus a RolesGuard is a NestJS documentation classic and takes ten minutes to build.
The problem is what happens next, and it happens to everyone.
Role explosion: how plain roles break down
Product asks: "support agents should be able to view billing but not refund". You don't have that role. So you add support_billing. Then "senior support can refund up to $50" — support_billing_refund. Then a customer wants their staff to manage posts but not users, but your editor role bundles both. Now you're choosing between editor_no_users, or — worse — adding an if inside the controller that special-cases the check.
This is role explosion: every new combination of capabilities demands a new role, because the role is the atom of your system. Roles multiply, their meanings drift, nobody remembers what manager_v2 actually grants, and auditing "who can delete users?" requires reading source code. The signature smell is role names that describe permissions (support_billing_refund) instead of jobs (support agent) — at that point the roles are permissions, just badly normalized.
The fix isn't more roles. It's demoting roles from "the thing endpoints check" to "a named bundle of permissions".
Permission-per-action: the model that holds up
Define a flat vocabulary of fine-grained permissions, one per protected action, namespaced by resource:
posts:read posts:write posts:delete
users:read users:manage
billing:view billing:refund
Endpoints require permissions. Roles are just labeled sets of them:
const ROLE_PERMISSIONS: Record<string, Permission[]> = {
admin: ['posts:read', 'posts:write', 'posts:delete',
'users:read', 'users:manage', 'billing:view', 'billing:refund'],
editor: ['posts:read', 'posts:write'],
support: ['users:read', 'billing:view'],
};
Now the support-can-refund request is a one-line change to a mapping — no new role, no code change, no redeploy if the mapping lives in the database. Endpoints never change, because billing:refund means the same thing forever; only who holds it varies.
In NestJS this becomes a decorator and a guard. The decorator attaches metadata:
// require-permission.decorator.ts
export const PERMISSION_KEY = 'required_permission';
export const RequirePermission = (...permissions: Permission[]) =>
SetMetadata(PERMISSION_KEY, permissions);
The guard reads it and checks the user's resolved permission set:
// permissions.guard.ts
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(
private reflector: Reflector,
private permissionsService: PermissionsService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const required = this.reflector.getAllAndOverride<Permission[]>(
PERMISSION_KEY,
[context.getHandler(), context.getClass()],
);
if (!required?.length) return true;
const { user } = context.switchToHttp().getRequest();
const granted = await this.permissionsService.getPermissions(user.userId);
return required.every((p) => granted.has(p));
}
}
And usage reads like documentation:
@Post('billing/:id/refund')
@RequirePermission('billing:refund')
refund(@Param('id') id: string) { ... }
One deliberate choice here: the guard resolves permissions server-side per request (cached in Redis with a short TTL) rather than embedding them in the JWT. Permission lists make tokens fat, and worse, they make revocation lag token expiry — demote someone and they keep their old powers until the token dies. I covered why tokens should stay small and dumb in JWT authentication in NestJS; authorization data is exactly the kind of frequently-changing state that doesn't belong in a signed snapshot.
ABAC: when "which one?" starts to matter
Permission-per-action still answers a binary question: can this user perform this action, in general. It cannot answer: can this user edit this post? RBAC says "editors can edit posts" — but you almost certainly mean "editors can edit their own posts, and admins can edit any".
ABAC generalizes the check into a policy over attributes — of the subject (user id, department, tenant), the resource (owner, status, tenant), the action, and sometimes the environment (time of day, IP range):
// A policy is just a predicate over subject + resource
const canEditPost = (user: User, post: Post): boolean =>
user.permissions.has('posts:write') &&
(post.authorId === user.id || user.permissions.has('posts:manage-any')) &&
post.status !== 'archived';
Notice this contains the RBAC check — ABAC isn't a replacement, it's a superset. In practice every real system I've built is a hybrid: coarse permission checks at the route (guard), fine attribute checks in the service layer where the resource has been loaded:
// posts.service.ts
async update(user: AuthUser, postId: string, dto: UpdatePostDto) {
const post = await this.postsRepo.findOneOrFail(postId);
if (post.authorId !== user.id && !user.permissions.has('posts:manage-any')) {
throw new ForbiddenException('You can only edit your own posts');
}
// ...
}
Guards can't do this alone — the resource isn't loaded yet when the guard runs, and loading it twice (once in the guard, once in the handler) is a pattern I've tried and abandoned; it doubles queries and splits the logic across two files.
RBAC vs ABAC head to head
| RBAC (with permissions) | ABAC | |
|---|---|---|
| Decision input | User's roles → permission set | Attributes of user, resource, action, context |
| Granularity | Per action type | Per action on a specific resource |
| Typical check | granted.has('posts:write') |
post.authorId === user.id && ... |
| Where it runs | Guard, before the handler | Service layer, after loading the resource |
| Cost per check | Cheap (cached set lookup) | A resource fetch you were doing anyway |
| Auditability | Excellent — enumerate the mapping | Harder — policies are code |
| "Can X do Y?" answerable statically | Yes | Only per-resource |
| Breaks down when | Ownership/tenancy rules appear | Policies sprawl without structure |
| Sweet spot | Route-level gating, admin panels | Ownership, tenancy, workflow states |
The honest summary: RBAC-with-permissions is the backbone; ABAC policies are the joints. You need both, and pretending otherwise just means writing ABAC checks ad-hoc inside controllers and calling it RBAC.
Storing the mappings
Three tables, nothing clever: roles, permissions, role_permissions, plus user_roles. Seed the permission vocabulary from code (it is code — endpoints reference it by string), but keep role→permission assignments in the database so an admin UI can edit them without a deploy. Cache the resolved per-user permission set in Redis, keyed by user, invalidated when their roles or the role's permissions change. The resolution query runs at login and on cache miss, not per request.
One rule I hold firmly: permission strings are append-only. Renaming posts:write to content:write silently un-protects every decorator that still says the old string. If you must rename, grant both during a migration window and grep for stragglers.
Multi-tenant wrinkles
The moment your app serves multiple organizations, every question above gets a "...in which tenant?" suffix. A user can be admin in workspace A and a plain member in workspace B — so user_roles grows a tenant_id column, and the permission cache is keyed by (userId, tenantId). The guard resolves the active tenant from the subdomain or a header before resolving permissions, and every ABAC policy gains a non-negotiable first clause: resource.tenantId === user.activeTenantId. That clause is your last line of defense against cross-tenant data leaks, and it belongs in the service layer where the resource is in hand — I went deep on tenant resolution and per-domain admin panels in the SaaS multi-tenant, multi-domain architecture post if you're building in that direction.
Where CASL fits
If your ABAC policies are multiplying, CASL (@casl/ability) is the library I'd reach for before inventing a policy engine. You define abilities per user — can('update', 'Post', { authorId: user.id }) — and get a uniform ability.can(action, subject) check that works in guards, services, and even the frontend (share the ability definition and your UI can hide buttons the API would reject). NestJS's docs have a solid CASL integration chapter. My threshold: under ~10 policies, plain functions like canEditPost are simpler and easier to grep; past that, CASL's structure and its accessibleBy query-scoping start paying for themselves. Like most architecture choices — same story as choosing between HTTP and gRPC — the framework earns its complexity only once the hand-rolled version starts hurting.
Wrapping up
The progression is almost universal, so you might as well plan for it: role checks get you shipped, role explosion forces you to permissions, and ownership rules force you into ABAC territory whether you name it that or not. The architecture that's held up for me: a flat, append-only permission vocabulary; roles as database-editable bundles of permissions; a @RequirePermission() guard doing cheap coarse checks at the route; attribute policies in the service layer for anything that depends on which resource; and tenant scoping as the outermost, never-skipped clause. Start with the permission layer even when roles feel sufficient — it's a day of work up front, versus a migration across every controller once support_billing_refund_v2 shows up in a code review.