Skip to content

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:

NIST ConceptOur Domain
UserAuthenticated session from verifySession()
Roleadmin, editor, author, viewer
Object (Resource)document, project
Operation (Action)create, read, update, delete, publish
PermissionRole-to-(resource, action) mapping

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[].

typescript
// lib/permissions.ts// No 'server-only' -- this file is shared between server and client
export const RESOURCES = ['document', 'project'] as const;export type Resource = (typeof RESOURCES)[number];
export const ACTIONS = [  'create',  'read',  'update',  'delete',  'publish',] as const;export type Action = (typeof ACTIONS)[number];
// Template literal type: "document:create" | "document:read" | ...export type Permission = `${Resource}:${Action}`;

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

typescript
export const ROLES = ['admin', 'editor', 'author', 'viewer'] as const;export type Role = (typeof ROLES)[number];

The Permission Map

This is the core of the RBAC system: a single object that defines every role's permissions.

typescript
export const ROLE_PERMISSIONS = {  admin: [    'document:create',    'document:read',    'document:update',    'document:delete',    'document:publish',    'project:create',    'project:read',    'project:update',    'project:delete',  ],  editor: [    'document:create',    'document:read',    'document:update',    'document:publish',    'project:read',  ],  author: [    'document:create',    'document:read',    'document:update',    'project:read',  ],  viewer: [    'document:read',    'project:read',  ],} as const satisfies Record<Role, readonly Permission[]>;

Two TypeScript features work together here:

  • as const preserves literal types; each permission is 'document:create', not string
  • satisfies validates 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.

Permissionadmineditorauthorviewer
document:createyesyesyes--
document:readyesyesyesyes
document:updateyesyesyes--
document:deleteyes------
document:publishyesyes----
project:createyes------
project:readyesyesyesyes
project:updateyes------
project:deleteyes------

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.

typescript
// lib/permissions.ts (continued)
export function can(  role: Role,  resource: Resource,  action: Action): boolean {  const permissions = ROLE_PERMISSIONS[role];  const required = `${resource}:${action}` as Permission;  return (permissions as readonly Permission[]).includes(required);}
// Alternative: check with a full permission stringexport function hasPermission(  role: Role,  permission: Permission): boolean {  return (ROLE_PERMISSIONS[role] as readonly Permission[]).includes(    permission  );}

Key characteristics:

  • Pure function: No database access, no async, no side effects. Zero overhead.
  • Type-safe: TypeScript narrows role to 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 same ROLE_PERMISSIONS map.

Before and After: Service Layer Refactoring

Before (hardcoded checks from Post 102):

typescript
function canEditDocument(  session: { userId: string; role: string },  document: DocumentWithProject): boolean {  if (session.role === 'admin') return true;
  const membership = document.project.members.find(    (m) => m.userId === session.userId  );  if (!membership) return false;
  if (membership.role === 'editor') return true;
  if (    membership.role === 'author' &&    document.authorId === session.userId  ) {    return true;  }
  return false;}

After (RBAC with can()):

typescript
function canEditDocument(  session: { userId: string; role: Role },  document: DocumentWithProject): boolean {  // Role-based check: does the role have document:update?  if (can(session.role, 'document', 'update')) return true;
  // Ownership check: authors can edit their own documents  // (This is where RBAC reaches its limit -- see Limitations)  const membership = document.project.members.find(    (m) => m.userId === session.userId  );  if (!membership) return false;
  if (    membership.role === 'author' &&    document.authorId === session.userId  ) {    return true;  }
  return false;}

What changed:

  • if (session.role === 'admin') return true became if (can(session.role, 'document', 'update')) return true
  • The admin check is no longer special-cased. It works because admin has document:update in 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 an if/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:

typescript
export const ACTIONS = [  'create',  'read',  'update',  'delete',  'publish',  'archive',  'invite-member',  'manage-settings',] as const;

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:

typescript
function canEditInDepartment(  session: { userId: string; role: Role; departmentId: string },  document: DocumentWithProject): boolean {  // Step 1: Does the role have the permission?  if (!can(session.role, 'document', 'update')) {    return false;  }
  // Step 2: Is the user in the same department?  if (session.departmentId !== document.project.departmentId) {    // Admin bypasses department restriction    if (session.role !== 'admin') return false;  }
  return true;}

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

typescript
function canModifyDocument(  session: { userId: string; role: Role },  document: { authorId: string }): boolean {  // Global permission check via RBAC  if (can(session.role, 'document', 'update')) {    return true;  }
  // Ownership fallback: authors can always edit their own  return document.authorId === session.userId;}

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:

typescript
// components/document-actions.tsximport { verifySession } from '@/lib/auth';import { can, type Role } from '@/lib/permissions';
export async function DocumentActions({  document,}: {  document: DocumentDTO;}) {  const session = await verifySession();  if (!session) return null;
  const role = session.role as Role;
  return (    <div>      {can(role, 'document', 'update') && (        <EditButton documentId={document.id} />      )}      {can(role, 'document', 'delete') && (        <DeleteButton documentId={document.id} />      )}      {can(role, 'document', 'publish') && (        <PublishButton documentId={document.id} />      )}    </div>  );}

Compare to Post 102's version:

typescript
// Post 102: duplicated logic, prone to driftconst canEdit =  session.role === 'admin' ||  session.role === 'editor' ||  document.authorId === session.userId;
// Post 103: shared source of truthconst canEdit = can(role, 'document', 'update');

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.

typescript
// Server Component parentimport { verifySession } from '@/lib/auth';import { can, type Role } from '@/lib/permissions';
export async function DocumentPage({  params,}: {  params: Promise<{ id: string }>;}) {  const { id } = await params;  const session = await verifySession();  const role = session?.role as Role;
  const permissions = {    canEdit: can(role, 'document', 'update'),    canDelete: can(role, 'document', 'delete'),    canPublish: can(role, 'document', 'publish'),  };
  return <DocumentToolbar permissions={permissions} />;}
typescript
// Client Component child'use client';
interface ToolbarProps {  permissions: {    canEdit: boolean;    canDelete: boolean;    canPublish: boolean;  };}
export function DocumentToolbar({ permissions }: ToolbarProps) {  return (    <div>      {permissions.canEdit && <EditButton />}      {permissions.canDelete && <DeleteButton />}      {permissions.canPublish && <PublishButton />}    </div>  );}

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:

typescript
// components/permission-gate.tsximport { verifySession } from '@/lib/auth';import {  can,  type Role,  type Resource,  type Action,} from '@/lib/permissions';
export async function PermissionGate({  resource,  action,  children,  fallback = null,}: {  resource: Resource;  action: Action;  children: React.ReactNode;  fallback?: React.ReactNode;}) {  const session = await verifySession();  if (!session) return fallback;
  const role = session.role as Role;
  if (!can(role, resource, action)) {    return fallback;  }
  return children;}

Usage:

typescript
<PermissionGate resource="document" action="delete">  <DeleteButton documentId={document.id} /></PermissionGate>

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.

typescript
// RBAC can answer this:can('editor', 'document', 'update'); // true
// RBAC cannot answer this:// "Can editor Bob update document XYZ?"// Depends on: Bob's project membership, XYZ's status, XYZ's author

Permission Matrix Explosion

As requirements grow, teams create increasingly specific permissions:

  • document:update: general
  • document:update-own: only your own
  • document:update-in-department: only in your department
  • document:update-draft: only drafts
  • document: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:

typescript
// These all exist alongside can()canEditOwnDocument(session, document);canEditInDepartment(session, document);canPublishInReviewStatus(session, document);canDeleteIfNotLocked(session, document);canAccessDraftInProject(session, document, project);

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.

typescript
// Preview -- full implementation in Post 104// Instead of:if (can(session.role, 'document', 'update')) {  // still need ownership check separately}// We'll use:if (evaluate(session, 'update', document)) {  // ownership, department, status -- all in one policy}

The service layer remains. The architecture does not change. Only the decision engine inside the permission checks evolves.

References

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.

Progress3/6 posts completed

Related Posts