Role-Based Access Control: Type-Safe RBAC in TypeScript
Build a type-safe RBAC system with TypeScript, create a unified can() function, synchronize permissions across UI and backend, and understand when RBAC reaches its limits.
Abstract
Post 102 centralized authorization in the service layer, solving the architectural question of where permission checks happen. But the permission rules inside the service layer are still hardcoded if/else chains: if (session.role === 'admin') return true. Adding a new role means modifying every helper function across every service file.
This post replaces those chains with RBAC (Role-Based Access Control), formalized by Ferraiolo and Kuhn in 1992 and standardized by NIST as INCITS 359-2004. A single, type-safe can() function replaces all hardcoded checks. It works on both server and client, eliminating the permission logic duplication that Post 102 acknowledged as a limitation.
What Is RBAC?
The NIST Model
RBAC assigns permissions to roles, not directly to users. Users acquire permissions by being assigned to roles. This indirection is the core insight: when someone changes jobs, change their role, not fifty individual permissions.
The NIST model defines three core components:
- Users: Authenticated identities (from
verifySession()in our case) - Roles: Named job functions: admin, editor, author, viewer
- Permissions: Approved operations on resources:
document:create,project:read
Without roles, managing permissions requires N users times M permissions assignments. With roles, you manage a much smaller set: N user-to-role assignments plus R role-to-permission mappings.
RBAC in Our Domain
Mapping NIST concepts to the domain from Posts 101-102:
Users connect to roles. Roles connect to permissions. No user connects to a permission directly. This is the fundamental RBAC structure.
TypeScript Permission Definitions
Resources and Actions
The first step is defining what resources and actions exist. TypeScript's as const assertion preserves literal types instead of widening to string[].
The Permission type is a union of all valid Resource:Action combinations. Writing 'document:fly' would be a compile-time error. TypeScript generates this union automatically from the two as const arrays.
Roles
The Permission Map
This is the core of the RBAC system: a single object that defines every role's permissions.
Two TypeScript features work together here:
as constpreserves literal types; each permission is'document:create', notstringsatisfiesvalidates the shape without widening. If a role is misspelled or a permission string is invalid (like'document:fly'), TypeScript catches it at compile time. But the inferred type still retains the specific literal values, enabling autocomplete
Why satisfies instead of a type annotation? Writing const ROLE_PERMISSIONS: Record<Role, Permission[]> would widen all values to Permission[], losing the specific literal information. With satisfies, TypeScript knows exactly which permissions each role has.
The Permission Matrix
The ROLE_PERMISSIONS object above can be visualized as a table. This table is reviewable, auditable, and does not require reading code.
This table IS the permission system. "What can an editor do?" is answered at a glance.
The can() Function
Implementation
A single function replaces all hardcoded role checks.
Key characteristics:
- Pure function: No database access, no async, no side effects. Zero overhead.
- Type-safe: TypeScript narrows
roleto one of four literal types. Invalid resource or action strings are compile-time errors. - Two forms:
can(role, 'document', 'update')reads naturally in service methods.hasPermission(role, 'document:update')reads naturally in UI components. Both query the sameROLE_PERMISSIONSmap.
Before and After: Service Layer Refactoring
Before (hardcoded checks from Post 102):
After (RBAC with can()):
What changed:
if (session.role === 'admin') return truebecameif (can(session.role, 'document', 'update')) return true- The admin check is no longer special-cased. It works because
adminhasdocument:updatein the permission map. - Adding a "moderator" role that can edit documents means adding one entry to
ROLE_PERMISSIONS, not modifying this function. - The ownership check (
document.authorId === session.userId) remains as anif/else. RBAC cannot express "only if you own it." This is explicitly RBAC's limitation and the motivation for ABAC in Post 104.
Granular Permissions
Beyond CRUD: Expanding Actions
As an application grows, simple CRUD does not cover all operations. New actions can be added to the array:
The Permission template literal type automatically expands. document:archive, project:invite-member, and project:manage-settings all become valid permission strings without any additional type definitions.
Department-Based Permission Helper
When organizations have departments, a common pattern is checking both role permission AND department membership:
Notice the department check is outside the can() function. RBAC's can() only knows about roles and permissions. It has no concept of "which department" or "which specific resource." This works for one or two contextual checks. But as these multiply (department, ownership, time-of-day, document status), the helper functions proliferate. We are back to the same problem.
Ownership-Based Permission Helper
Again, the ownership check is outside RBAC. The can() function cannot express "only your own documents." This is by design. RBAC maps roles to permissions, period.
UI and Backend Synchronization
The Shared Permission Module
The key insight: lib/permissions.ts has no 'server-only' import. It contains no database access, no session handling, no server-specific APIs. It is a pure TypeScript module with type definitions, a constant object, and pure functions.
This means it can be imported by:
- Service layer files (server-only): for authorization
- React Server Components: for conditional rendering
- React Client Components: for conditional rendering
- Middleware: for route-level checks
One source of truth. Zero duplication.
Server Component Usage
Server Components can call verifySession() and use can() directly:
Compare to Post 102's version:
The can() call in the component and the can() call in the service layer query the same ROLE_PERMISSIONS object. If an editor gains delete permission, change it in one place. Both server and client reflect the change automatically.
Client Component with Passed Permissions
Client Components cannot call verifySession(). The solution: resolve permissions in a parent Server Component and pass them as props.
The Client Component knows nothing about the permission system. It receives booleans and renders accordingly.
The PermissionGate Component
For repeated permission checks in templates, a reusable wrapper reduces boilerplate:
Usage:
Warning: UI permission checks are for user experience only, not security. A hidden button can still be called via direct API request. The service layer remains the security boundary. UI checks prevent users from seeing actions they cannot perform; the service layer prevents them from executing actions they cannot perform.
RBAC Limitations: When can() Is Not Enough
Contextual Decisions
can(role, 'document', 'update') answers a general question: "Can editors update documents?" But the real question is often: "Can this editor update this specific document?" That depends on:
- Is the editor a member of the document's project?
- Is the document locked for editing?
- Is the document in "review" status?
- Is the user the document's author?
None of these can be expressed in the role-permission map. They require attributes of the user, the resource, and the environment.
Permission Matrix Explosion
As requirements grow, teams create increasingly specific permissions:
document:update: generaldocument:update-own: only your owndocument:update-in-department: only in your departmentdocument:update-draft: only draftsdocument:update-published: only published documents
The permission matrix explodes. This is the code-level equivalent of NIST's "role explosion" problem. Instead of creating new roles for every edge case, we are creating new permission strings; the same problem in disguise.
Helper Function Proliferation
To handle contextual checks alongside RBAC, helper functions multiply:
Each is a custom if/else chain. The can() function handles the role check, but the contextual logic is still imperative code. We have improved from Post 102, but the contextual checks remain scattered.
When to Use RBAC vs. When to Move Beyond
RBAC is the right tool when permissions depend primarily on the user's role: "Admins can do everything", "Editors can update and publish", "Viewers can only read." RBAC is the wrong tool when permissions depend on context: ownership, resource state, team membership, or environmental conditions.
What's Next
The can() function and the ROLE_PERMISSIONS map solve role-based authorization cleanly. Adding a new role is a one-line change. The permission matrix is inspectable and auditable. Server and client share a single source of truth.
But every contextual check (ownership, department scoping, document status) still lives outside RBAC as a custom helper function. As these multiply, the system loses the declarative clarity that RBAC provides.
In Post 104, we introduce Attribute-Based Access Control (ABAC). The can() function evolves to evaluate attributes of the user, the resource, and the environment through a policy engine. Ownership, department scoping, and resource state become policy rules, not hardcoded conditionals.
The service layer remains. The architecture does not change. Only the decision engine inside the permission checks evolves.
References
- NIST Role-Based Access Control (RBAC) Project - NIST's foundational project page for RBAC, including the formal definition and the INCITS 359 standard references
- The NIST Model for Role-Based Access Control (Sandhu, Ferraiolo, Kuhn, 2000) - The seminal paper defining the four RBAC model levels: flat, hierarchical, constrained, and symmetric RBAC
- OWASP A01:2025 - Broken Access Control - Broken access control remains the top risk in the OWASP Top 10, reinforcing why structured permission systems are critical
- OWASP Authorization Cheat Sheet - Best practices for centralized authorization logic, deny by default, and the principle of least privilege
- TypeScript 4.9: The satisfies Operator - Official documentation for the
satisfiesoperator used in theROLE_PERMISSIONSpattern - TypeScript Template Literal Types - Official documentation for template literal types that power the
Permissiontype - RBAC vs ABAC vs ReBAC (Web Dev Simplified) - Practical comparison of three permission models in TypeScript with code examples
- Role Explosion: The Hidden Cost of RBAC (Permify) - Analysis of role explosion causes, maintenance impact, and solutions through fine-grained access control
- CASL - Isomorphic Authorization JavaScript Library - The library that inspired the
can()function pattern; isomorphic, TypeScript-native authorization - Next.js Authentication Guide (Official) - Official Next.js documentation covering the Data Access Layer pattern and server-only authorization
- Role-Based Access Control (Wikipedia) - Comprehensive overview of RBAC history, the three NIST rules, and the RBAC0-RBAC3 model hierarchy
Permission Systems that Scale
A comprehensive guide to building scalable permission systems in TypeScript and Next.js, progressing from naive checks through RBAC and ABAC to production-grade multi-tenant authorization.