Build CoreOrdered learning track

RBAC in React Apps

Learn React Authentication, Authorization, Identity & Permission/ACL - Part 042

RBAC in React apps: users, roles, permissions, sessions, tenant-scoped roles, role hierarchy, constraints, separation of duties, permission projection, UI exposure, route metadata, admin UX, cache invalidation, migration from role checks, and testing.

10 min read1803 words
PrevNext
Lesson 42130 lesson track25–71 Build Core
#react#authorization#rbac#roles+6 more

Part 042 — RBAC in React Apps

RBAC is useful.

RBAC is also frequently abused.

The useful version:

Roles group permissions around stable responsibilities.
Users receive roles within a scope.
Permissions describe capabilities.
Authorization decisions still evaluate action/resource/context.

The abused version:

if (user.role === "admin") {
  return <DangerousButton />;
}

That code is not automatically wrong because it is in React.

It is wrong because it collapses an authorization decision into a label check.

RBAC in a serious React app is not about sprinkling isAdmin checks across components.

It is about designing a clean contract between:

  • backend role assignment,
  • permission derivation,
  • route/data authorization,
  • UI exposure,
  • tenant scope,
  • cache invalidation,
  • admin workflows,
  • auditability,
  • test matrices.

This part builds that model.


1. RBAC in one sentence

Role-Based Access Control assigns users to roles and grants permissions to roles.

user -> role -> permission

Then authorization uses those permissions as input.

A basic model:

Notice the last arrow.

Permission alone is not always the final answer.

A user may have case.approve in principle, but still be denied because:

  • the case is not in the user's tenant,
  • the case is not in the right workflow state,
  • the user created the case and separation-of-duty applies,
  • step-up authentication is required,
  • the permission only applies to a region/team/project,
  • the resource is sealed,
  • the grant expired.

RBAC gives structure.

It does not remove the need for resource/context checks.


2. The canonical RBAC objects

A practical RBAC system has these concepts:

ObjectMeaning
UserHuman account/principal.
RoleNamed bundle of responsibility.
PermissionCapability to perform an action.
User-role assignmentLink between user and role.
Role-permission assignmentLink between role and permissions.
SessionActive subset of roles used in a login/session.
ScopeBoundary where assignment applies: tenant, org, project, region.
ConstraintRule limiting assignment or activation.

The session part is often ignored in web apps.

But it matters.

A user may have multiple roles but only activate some depending on tenant, workspace, or impersonation context.

Example:

User: Mira
Tenant A: Case Reviewer
Tenant B: Tenant Admin
Current active tenant: Tenant A
Effective roles: Case Reviewer

React must not treat all roles across all tenants as globally active.


3. Role is not identity

Bad model:

type User = {
  id: string;
  email: string;
  role: "admin" | "user";
};

This creates several problems:

  • only one role,
  • no tenant scope,
  • no role version,
  • no assignment expiry,
  • no separation-of-duty constraints,
  • no distinction between identity profile and authorization subject,
  • hard migration when roles evolve.

Better:

type AuthenticatedSubject = {
  id: string;
  email: string;
  activeTenantId: string;
  effectiveRoles: Array<{
    roleKey: string;
    scope: { type: "tenant" | "project" | "region"; id: string };
    assignmentId: string;
    expiresAt?: string;
  }>;
  permissionVersion: string;
};

Even better, React often receives derived permissions instead of raw role internals:

type SessionProjection = {
  user: {
    id: string;
    displayName: string;
  };
  activeTenant: {
    id: string;
    name: string;
  };
  roles: Array<{
    key: string;
    label: string;
    scope: string;
  }>;
  permissions: Record<string, "allow" | "deny">;
  permissionVersion: string;
};

The UI may show roles.

The UI should usually decide actions through permissions/decisions.


4. The user.role === admin trap

This pattern spreads quickly:

{user.role === "admin" && <AdminPanel />}

Then later:

{(user.role === "admin" || user.role === "manager") && <ExportButton />}

Then:

{(user.role === "admin" || user.role === "manager" || user.email.endsWith("@company.com")) && (
  <ExportButton />
)}

This is how authorization becomes archaeology.

Problems:

1. Role semantics are duplicated across components.
2. UI policy drifts from backend policy.
3. New roles require code search across the app.
4. Object-level rules cannot fit.
5. Tenant-scoped roles are mishandled.
6. Negative cases become hard to test.
7. Denial reasons are unavailable.
8. Product teams start asking for roles to solve feature toggles.

A better pattern:

<Can action="report.export" resource={{ type: "report", id: reportId }}>
  <ExportButton />
</Can>

Or:

const exportDecision = useCan("report.export", { type: "report", id: reportId });

Role remains a backend/admin concept.

Permission becomes the app contract.

Decision becomes the runtime truth.


5. RBAC belongs behind a permission contract

A React app should not need to know that case.approve comes from:

  • senior_case_reviewer,
  • tenant_admin,
  • temporary emergency role,
  • direct grant,
  • role hierarchy,
  • delegation,
  • policy exception.

React needs to know:

Can this subject approve this case?
If not, why and what recovery exists?

A clean boundary:

Frontend contract:

type PermissionKey =
  | "case.read"
  | "case.create"
  | "case.assign"
  | "case.approve"
  | "case.close"
  | "case.comment.create"
  | "case.evidence.download"
  | "tenant.member.invite"
  | "role.assignment.manage";

Role definition remains backend-owned:

{
  "roleKey": "case_reviewer",
  "label": "Case Reviewer",
  "permissions": [
    "case.read",
    "case.comment.create",
    "case.approve"
  ]
}

React consumes the resulting projection.


6. Permission naming

Permission names should be domain capabilities.

Bad:

showApproveButton
adminPageAccess
canClickBlueButton
modal.case.approve.visible

Good:

case.approve
case.assign
case.evidence.download
case.audit.read
user.invite
role.assignment.manage
report.export

A useful convention:

<resource>.<action>
<resource>.<subresource>.<action>
<domain>.<operation>

Examples:

case.read
case.update
case.penalty.edit
case.evidence.upload
case.evidence.download
case.audit.read
workflow.transition.approve
tenant.member.invite
tenant.sso.configure
role.assignment.create
role.assignment.delete

Do not overfit permission names to UI.

The UI will change.

The domain operation should survive.


7. Role design

Good roles represent stable responsibility.

Examples:

Tenant Admin
Case Reviewer
Case Manager
Evidence Uploader
Audit Viewer
Billing Admin
Support Agent
Read-only Analyst

Bad roles represent temporary UI desires:

CanSeeNewDashboard
ExportButtonUser
BlueThemeBetaAdmin
ApproveModalEnabled

Those are feature flags, experiments, or product configuration.

Not roles.

A role should answer:

What responsibility does this person have in the organization or workflow?

A permission should answer:

What operation can be performed?

A feature flag should answer:

Is this feature released or configured for this cohort/environment?

Do not merge them.


8. Role hierarchy

Role hierarchy lets one role inherit another.

Example:

This can reduce duplication.

But role hierarchy can also hide dangerous privilege.

If Tenant Admin inherits every operational permission, then tenant admins may accidentally gain business-action authority they should not have.

Example mistake:

Tenant Admin can manage users.
Case Manager can approve cases.
Tenant Admin inherits Case Manager.
Now tenant admin can approve cases.

That may violate separation of duties.

Safer design:

Administrative roles and operational roles are separate.

Example:

Tenant Admin: manage members, configure SSO, manage billing.
Case Manager: assign and approve cases.
Audit Viewer: read audit logs.

A person may hold multiple roles, but assignment is explicit and auditable.


9. Role scope

In SaaS and enterprise apps, role scope is mandatory.

A role assignment must say where it applies.

type RoleAssignment = {
  userId: string;
  roleKey: string;
  scope:
    | { type: "global" }
    | { type: "tenant"; tenantId: string }
    | { type: "project"; projectId: string }
    | { type: "case"; caseId: string };
  createdAt: string;
  createdBy: string;
  expiresAt?: string;
};

Without scope, React will leak permissions across contexts.

Bad cache key:

["me", "permissions"]

Better cache key:

["me", "permissions", activeTenantId, permissionVersion]

For project-scoped roles:

["project", projectId, "permissions", subjectId, permissionVersion]

A user can be:

Tenant Admin in Tenant A
Read-only Viewer in Tenant B
No member in Tenant C

One session.

Different effective roles per tenant.

React must derive UI from the active scope.


10. Constraints and separation of duties

RBAC becomes powerful when it includes constraints.

Examples:

A user cannot both create and approve the same case.
A user cannot grant themselves Tenant Admin.
A user cannot hold Billing Admin and Audit Suppressor.
A temporary role expires after 2 hours.
A support agent role requires active ticket context.
A high-risk approval requires MFA.

Types of constraints:

ConstraintMeaning
Static separation of dutiesUser cannot be assigned conflicting roles.
Dynamic separation of dutiesUser cannot activate conflicting roles in same session/action.
Prerequisite roleRole requires another role or verified state.
CardinalityOnly N users can hold a role.
TemporalRole expires or only applies during time window.
ContextualRole applies only for a support ticket or tenant.
Self-service restrictionUser cannot grant/revoke their own privileged role.

React must not enforce these constraints alone.

But React must represent their effects.

Example denial:

{
  "effect": "deny",
  "reason": "separation_of_duties",
  "messageKey": "case.approve.denied.creatorCannotApprove"
}

This lets UI give a precise, safe explanation.


11. RBAC and route metadata

Route metadata can declare required capability.

export const handle = {
  auth: {
    required: true,
    permission: "tenant.member.invite"
  }
};

But do not put role names in route metadata by default.

Weak:

export const handle = {
  roles: ["admin", "owner"]
};

Better:

export const handle = {
  authz: {
    action: "tenant.member.invite",
    resource: { type: "tenant", source: "activeTenant" }
  }
};

Why?

Because later, tenant.member.invite might be granted to:

  • Tenant Admin,
  • Owner,
  • delegated HR Manager,
  • temporary onboarding specialist,
  • break-glass admin.

The route should not need to change.

Policy changes should happen in policy/role configuration, not in random route files.


12. RBAC and component-level UI

A permission-aware component should consume a decision.

type CanProps = {
  action: PermissionKey;
  resource?: ResourceRef;
  children: React.ReactNode;
  fallback?: React.ReactNode;
};

function Can({ action, resource, children, fallback = null }: CanProps) {
  const decision = useCan(action, resource);

  if (decision.status !== "ready") return fallback;
  if (decision.value.effect !== "allow") return fallback;

  return <>{children}</>;
}

Usage:

<Can action="case.approve" resource={{ type: "case", id: case.id }}>
  <ApproveCaseButton caseId={case.id} />
</Can>

For disabled actions with reasons:

function PermissionedButton({ action, resource, children, ...buttonProps }: Props) {
  const decision = useCan(action, resource);

  if (decision.status !== "ready") {
    return null;
  }

  if (decision.value.effect === "deny") {
    return (
      <Button disabled title={mapReason(decision.value.reason)} {...buttonProps}>
        {children}
      </Button>
    );
  }

  return <Button {...buttonProps}>{children}</Button>;
}

Whether to hide or disable depends on product/security context.

Hide when:

the user should not know the capability exists,
the action is irrelevant,
it would leak sensitive workflow.

Disable when:

the action is visible but unavailable,
the user needs explanation,
request-access workflow exists,
workflow state explains availability.

13. Permission projection from /session or /me

For global/navigation permissions, a session endpoint may return projection.

{
  "user": {
    "id": "u_123",
    "displayName": "Mira"
  },
  "activeTenant": {
    "id": "t_001",
    "name": "Acme"
  },
  "roles": [
    { "key": "case_reviewer", "label": "Case Reviewer", "scope": "tenant:t_001" }
  ],
  "permissions": {
    "case.list": "allow",
    "case.create": "deny",
    "tenant.member.invite": "deny"
  },
  "permissionVersion": "t_001-membership-v42"
}

This is useful for:

  • sidebar,
  • route coarse access,
  • command palette,
  • account menu,
  • dashboard card visibility.

But resource-specific screens need resource-specific decisions.

Example:

GET /api/cases/case_123

Response:

{
  "case": {
    "id": "case_123",
    "status": "UNDER_REVIEW"
  },
  "permissions": {
    "case.read": { "effect": "allow" },
    "case.approve": { "effect": "allow" },
    "case.assign": {
      "effect": "deny",
      "reason": "insufficient_permission"
    }
  }
}

Do not use global role alone to decide per-case actions.


14. Admin UX for RBAC

RBAC is not just runtime checks.

It also needs administration.

A serious role admin UI should show:

role name and purpose
permissions included
scopes where role can be assigned
who currently has the role
who granted the role
when it was granted
when it expires
approval status
risk level
conflicting roles
blast radius preview
audit history

Before saving a role change, show a diff:

Added permissions:
+ case.approve
+ case.evidence.download

Removed permissions:
- case.comment.create

Affected users:
37 users across 4 tenants

High-risk change:
case.evidence.download exposes restricted files

Do not let admin users edit JSON blindly unless the organization is small and trusted.

Role editing is security-sensitive product design.


15. Role assignment workflow

Granting a role is itself an authorization-sensitive action.

Questions:

Who can grant this role?
Can they grant it in this tenant?
Can they grant it to themselves?
Does the role require approval?
Does the role expire?
Does the grant require MFA?
Does the user already have a conflicting role?
Is the target user eligible?
Is the grant audited?

Flow:

React should not directly mutate role state optimistically without backend confirmation.

A role grant is not a normal preference toggle.


16. Role explosion

Role explosion happens when every exception becomes a role.

Symptoms:

AdminEastRegionReadOnlyTemporaryExceptBilling
SeniorReviewerCanExportButNotApprove
ManagerWithoutEvidenceDownload
CaseReviewerLevel2BetaDashboard

Causes:

  • using roles for object-specific access,
  • using roles for feature flags,
  • using roles for workflow state,
  • using roles for temporary exceptions,
  • no permission granularity,
  • no scoped grants,
  • no ABAC/ReBAC/ACL support.

Fixes:

1. Keep roles tied to stable responsibilities.
2. Add permission granularity.
3. Add scope to assignments.
4. Use ABAC for attribute/context rules.
5. Use ReBAC/ACL for collaboration/sharing.
6. Use feature flags for release configuration.
7. Use temporary grants for exceptions.

In React, role explosion appears as conditional sprawl.

if (
  user.roles.includes("admin") ||
  user.roles.includes("admin_east") ||
  user.roles.includes("admin_east_readonly") ||
  user.roles.includes("support_level_2")
) {
  // nobody knows why this is here
}

Replace with:

const canExport = useCan("report.export", reportResource);

17. RBAC and feature flags

Feature flags are not permissions.

Permission says:

This subject is allowed to perform this operation.

Feature flag says:

This feature is enabled for this environment/user/cohort/tenant.

Both may be required.

Example:

Feature flag: advanced_exports is enabled for tenant Acme.
Permission: user can report.export.
Resource policy: report contains no sealed data.

React condition:

if (flags.advancedExports && decision.effect === "allow") {
  return <AdvancedExportButton />;
}

Backend condition:

flag enabled AND authorization allowed

Do not ship backend enforcement only as a frontend flag.

Do not grant access only because a flag is enabled.


18. RBAC and tenant switching

Tenant switch changes effective roles.

On tenant switch:

cancel in-flight tenant-scoped requests
clear route loader data
invalidate TanStack Query tenant keys
reload /session or /me projection
clear permission cache
close sensitive modals
reset selected rows/actions
unsubscribe from tenant WebSocket channels
navigate to safe tenant landing page

Example:

async function switchTenant(nextTenantId: string) {
  authzStore.clear();
  queryClient.cancelQueries();
  queryClient.removeQueries({ queryKey: ["tenant"] });

  await api.post("/session/active-tenant", { tenantId: nextTenantId });

  const session = await queryClient.fetchQuery({
    queryKey: ["session", nextTenantId],
    queryFn: fetchSession
  });

  router.navigate(`/${session.activeTenant.slug}/dashboard`, { replace: true });
}

The exact code depends on architecture.

The invariant does not:

Never reuse old tenant permission projection after tenant switch.

19. RBAC and impersonation

Impersonation changes effective subject.

A safe impersonation session needs:

actor user: the admin/support person
subject user: the person being impersonated
effective roles: constrained by impersonation policy
visible banner: always
exit action: always visible
audit: every action includes actor and subject
restriction: no sensitive self-service operations unless explicitly allowed

Example subject:

type ImpersonatedSubject = {
  type: "impersonated-user";
  actorUserId: string;
  effectiveUserId: string;
  tenantId: string;
  constraints: ["no_billing_changes", "no_role_grants"];
};

React must not show the app as if the admin simply became the user.

That hides accountability.

Every permission decision should know impersonation is active.


20. Cache invalidation for RBAC

Role changes must invalidate permission state.

Events:

role assigned
role revoked
role definition changed
role permission added/removed
membership removed
tenant switched
session refreshed with changed claims
impersonation started/exited
admin changed user's role in another tab
policy version changed

Possible mechanisms:

  • short TTL on /session,
  • permission version in session projection,
  • server-sent invalidation event,
  • WebSocket event,
  • BroadcastChannel event between tabs,
  • reload on 403 after stale allow,
  • revalidate route loader after mutation.

React can maintain an authz epoch:

let authzEpoch = 0;

function bumpAuthzEpoch(reason: string) {
  authzEpoch += 1;
  queryClient.invalidateQueries({ queryKey: ["permissions"] });
  broadcast.postMessage({ type: "authz:changed", authzEpoch, reason });
}

Queries include the epoch:

["permissions", activeTenantId, authzEpoch]

Use carefully.

Do not let epoch become a substitute for server-side enforcement.


21. Migration from role checks to permission checks

Most legacy React apps start with role checks.

Migration path:

1. Inventory all role checks.
2. Group them by domain operation.
3. Name permissions for those operations.
4. Add backend permission projection.
5. Replace component role checks with useCan/Can.
6. Add backend enforcement if missing.
7. Add test matrix for each permission.
8. Remove dead role-specific UI conditionals.
9. Add lint/static checks to prevent new role checks.

Inventory example:

grep -R "role" src/
grep -R "isAdmin" src/
grep -R "roles.includes" src/

Migration mapping:

Old checkNew permission
role === "admin" around user invitetenant.member.invite
role === "manager" around approvecase.approve
role === "auditor" around audit tabcase.audit.read
role === "support" around impersonationsupport.impersonate

Do not do a mechanical rename from role to permission.

Redesign around operations.


22. Testing RBAC in React

Test both projection and enforcement behavior.

Component matrix:

Effective permissionsExpected UI
no case.approveApprove button hidden/disabled
case.approve allowedApprove button visible
case.approve denied step-upStep-up prompt visible
permission loadingno protected flash
permission errorsafe disabled/fallback

Route matrix:

Role/permissionRouteExpected
anonymous/admin/userslogin redirect
authenticated no permission/admin/users403/no access
tenant admin/admin/usersallow
tenant admin wrong tenant/tenant-b/admin/usersdeny

Mutation matrix:

UI thoughtBackend resultExpected UI
allowallowsuccess
allow403 stale permissionrollback + revalidate
loadingsubmit attemptedblock or recheck
denyuser hacks requestbackend denies

Use fixtures that reflect real role assignments.

Bad test fixture:

const user = { role: "admin" };

Better:

const session = buildSession({
  activeTenantId: "t_001",
  roles: ["case_reviewer"],
  permissions: {
    "case.read": "allow",
    "case.approve": "allow",
    "tenant.member.invite": "deny"
  },
  permissionVersion: "v42"
});

23. Static guardrails

Prevent old patterns from returning.

Examples:

Disallow user.role checks outside authz adapter.
Disallow roles.includes in components.
Require permission key constants.
Require route metadata for protected route groups.
Require tests for new permissions.
Require backend enforcement reference in PR template.

ESLint custom rule idea:

no-direct-role-check-in-ui

Allowed:

// src/authz/roleDisplay.ts
formatRoleLabel(roleKey)

Disallowed:

// src/features/cases/ApproveButton.tsx
user.roles.includes("case_manager")

Roles may still be displayed.

Roles should not be scattered as authorization logic.


24. Production checklist

Before using RBAC in a React feature, ask:

[ ] Is the role scoped to tenant/project/global?
[ ] Is the UI checking permission/decision instead of raw role?
[ ] Is the backend enforcing the same operation?
[ ] Is the permission name domain-oriented?
[ ] Does the decision include resource/context when needed?
[ ] Are role assignments auditable?
[ ] Are role changes invalidating permission cache?
[ ] Are separation-of-duty constraints represented?
[ ] Is impersonation handled explicitly?
[ ] Are denial states tested?
[ ] Are tenant switches clearing old projections?
[ ] Are admin role changes protected by MFA/approval if high risk?
[ ] Is role hierarchy intentional and reviewed?
[ ] Are feature flags separate from permissions?

If RBAC is not scoped, audited, invalidated, and tested, it is only a naming convention.


25. The mental compression

RBAC is a way to manage permissions.

It is not the whole authorization system.

Keep this model:

Users get roles within scopes.
Roles grant permissions.
Permissions describe capabilities.
Decisions evaluate permissions plus resource/context.
React consumes decisions for exposure.
Backend enforces decisions for reads/writes.
Role changes invalidate permission state.
Admin workflows audit every grant/revoke.

When RBAC is clean, React code becomes simpler.

Not because security became simple.

Because policy complexity moved to the right boundary.

The next parts will build on this:

permission-based UI,
ACL and object-level permission,
ABAC,
ReBAC/Zanzibar-style permissions,
policy as data vs policy as code,
frontend permission contracts.

RBAC is the first layer.

Do not make it carry every authorization problem.


Sources

  • NIST RBAC model publications — users, roles, permissions, sessions, hierarchies, and constraints.
  • OWASP Authorization Cheat Sheet — deny-by-default, validate permissions on every request, least privilege, audit authorization events.
  • OWASP Top 10 2021 A01 Broken Access Control — failures from missing/weak access control and violation of least privilege.
  • NIST SP 800-162 — useful contrast with ABAC for context/attribute-driven decisions.
Lesson Recap

You just completed lesson 42 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.