6.1.A guide to implementing RBAC and ACL with React.

Published:

By Misael Cázares

[ 6.Application Architecture ]

This is a practical reference to implementing Role-Based Access Control (RBAC) and Access Control Lists (ACL) in React applications. We will cover architecture decisions and coding patterns to implement these security mechanisms. While we will be focusing on the front-end and UX implementations, it is important to note that the same principles apply to our back-end applications, and we should always make sure to validate user permissions both on the client and on the server, one shouldn't substitute the other.

Core concepts

One of the most important architectural decisions we need to make when starting a new application is how we are going to control access to our features and resources. In a real world scenario, not all users will have access to every feature on our app, and some shared resources will need specific access controls, not all consumers of a shared resource will have the same level of access to perform specific actions.

Most applications I have seen implement only basic Role-Based Access Control, a user may be able to edit, view and create documents, others may be able to just read them, with no access to create or to edit existing ones, and we gate our application routes or disable some specific elements on the UI, based on the current user role. While that's completely fine, that won't play out well in large scale applications with many features and different sets of users. We need to provide software administrators tools that allow them to have more control and flexibility over the access controls, once the applications and users start to grow.

That's when Access Control Lists (ACL) come into play, besides validating user access to features through roles, we can also attach rules to specific resources, with fine-grained permissions, that can override or extend RBAC.

  • *

    RBAC assigns permissions through roles. A user has a role; that role has a set of permissions. Roles are broad (admin, editor, viewer). "User can edit posts".

  • *

    ACL attaches permissions directly to specific resource instances. Fine-grained. Can override or extend RBAC. "User can edit this post".

With RBAC and ACL we get a pretty flexible and robust access control system for our applications.

Remember that both of these patterns need to be implemented in both, the client and the server. The client validations are just good UX, but the real security enforcement is in the server.

We also may need Attribute-Based Access Control (ABAC) for even more complex scenarios, you can think of this as a rule-based approach to validate user actions based on context or specific properties/attributes of a resource. "Can this user edit this file at this specific point in time?", "Can this user edit this file from this environment?". By being a rule-based approach, we can just extend our existing RBAC and ACL system with custom rules in the procedures to check not only previous access records, but also existing resource attributes.

Now let's look at the specifics of how to implement the RBAC and ACL patterns in a React application.

Defining Roles and Permissions

The first thing we need to define before implementing any of these patterns, is the list of permissions that both client and server will have to agree on. This is the single most important building block, as everything else will reference it. It can be a plain constant, typed for both server and client, depending on the language or framework of choice for the application. What's important is that it should be kept as a typed registry to catch errors at build time and avoid runtime mismatches in our validations. It's also important we keep it as the single source of truth. We don't want our backend and client validation results to diverge.

We can create a package to distribute this privately within our organization through an artifact repository in the cloud, a git submodule, or a package inside a monorepo. As long as we keep it as the single source of truth, we should be fine.

// permissions.ts — shared between client and server
export const PERMISSIONS = {
  POSTS_CREATE:   'posts:create',
  POSTS_EDIT:     'posts:edit',
  POSTS_DELETE:   'posts:delete',
  POSTS_PUBLISH:  'posts:publish',
  USERS_MANAGE:   'users:manage',
  DASHBOARD_VIEW: 'dashboard:view',
} as const
 
export type Permission = typeof PERMISSIONS[keyof typeof PERMISSIONS]
 

The mapping of role -> permissions shouldn't live here. The assignment of user -> role belongs to the database. If we hardcode "admin gets these six permissions", it means every permission or role update needs a new code change and deploy. Our permission registry is just a dictionary of valid actions in the system, the database decides which roles, or whom, can perform these actions.

The data

Now that we have defined our permission set, we need to think about how we are going to fetch our data. The front-end already knows the type of actions any user can perform (our permission registry), now we need to know the specific actions a particular user is allowed to perform.

For this we need two pieces of data: the user's permission set and the ACL grants for a resource instance. An admin of the system would be the person responsible for creating roles, attaching permissions to these roles, and then assigning the roles to users. The back-end application will be responsible for storing and tracking ACL records.

As we are gating our UI with fine-grained control over actions with permissions, we don't need to know the roles of the user, even though we could, but getting a flat list of permissions from the assigned roles should be more than enough. This information needs to be fetched as soon as a new session starts.

GET /api/me/permissions → ['posts:edit', 'posts:publish', 'dashboard:view']

For the ACL grants, this will be attached to the particular resource instance when we fetch it.

GET /api/posts/42  →  { id: '42', title: '...', acl: ['posts:edit'] }

The server already knows who's asking (from the session, JWT, etc), so when you request a post, it can resolve your grants on that post and include them in the response.

The server application will also be responsible for updating this ACL list. For example, if a user decides to give the permission to edit a particular document to another user, the server will have to create an ACL record to store that information, with the document and user reference, and the granted permissions.

Let's see how to share this data with our UI components.

Auth context

React Context is the right tool for the permission set, because it's read by components all over the tree and it changes rarely. We load it with React Query (so refetching and cache invalidation come for free) and expose two helpers.

// AuthContext.tsx
import { createContext, useContext } from 'react'
import { useQuery } from '@tanstack/react-query'
import type { Permission } from './permissions'
 
type AuthValue = {
  can: (permission: Permission) => boolean
  canOnResource: (permission: Permission, acl: string[]) => boolean
}
 
const AuthContext = createContext<AuthValue | null>(null)
 
export function AuthProvider({ children }: { children: React.ReactNode }) {
  const { data: permissions = [] } = useQuery({
    queryKey: ['me', 'permissions'],
    queryFn: () => api.get('/me/permissions'), // returns string[]
    staleTime: Infinity, // Never refetches on its own; invalidate explicitly on role change. When the server rejects a request for another resource, invalidate the queries to refresh. A re-login or session refresh should also invalidate the cache.
  })
 
  // type-level: Is this permission in my role's set? - Can I create this resource type?
  const can = (permission: Permission) => permissions.includes(permission)
 
  // instance-level: Decided per resource - Can I perform this action on this particular resource?
  const canOnResource = (permission: Permission, acl: string[]) =>
    acl.includes(permission)
 
  return (
    <AuthContext.Provider value={{ can, canOnResource }}>
      {children}
    </AuthContext.Provider>
  )
}
 
export const useAuth = () => {
  const ctx = useContext(AuthContext)
  if (!ctx) throw new Error('useAuth must be used inside AuthProvider')
  return ctx
}

Both helpers are synchronous. That's the payoff of fetching ACLs with the resource, by the time you're rendering a post, its acl array is already in hand, so checking it is a plain array lookup.

How role and instance access combine

This is the part that's easy to get wrong, so it deserves its own section. When you check instance-level access, you have to decide how a broad role permission interacts with a specific ACL grant. There are three honest answers, and the right one is a product decision, not a technical one.

Additive: the role is a baseline, ACL extends it. If your role already grants posts:edit globally, you can edit any post; the ACL is there to grant access to people whose role doesn't cover it. A viewer can be handed edit rights on one specific post.

const canOnResource = (permission: Permission, acl: string[]) =>
  can(permission) || acl.includes(permission)

This fits content tools where roles are meaningful tiers and ACL is the escape hatch for exceptions.

Explicit-only: the ACL is the sole authority for instances. Roles govern type-level things (which routes load, which features the user can use), but access to any specific resource always requires an explicit grant. Having the editor role doesn't give you edit access to every document; you can only edit the ones shared with you.

const canOnResource = (permission: Permission, acl: string[]) =>
  acl.includes(permission)

Override: ACL can also take access away. The role grants access by default, but a resource can carry an explicit decision that wins over the role, in either direction. Most powerful, but more complex. Only use it if you genuinely need to block roles from specific instances.

const canOnResource = (permission: Permission, acl: string[] | null) =>
  acl == null ? can(permission) : acl.includes(permission)

Pick one consciously, depending on the use case.

UI fine-grain control with one component

With our context and helpers in place now we can block specific actions in our UI. A single component can be used for both cases, to check instance-level and type-level access.

// Can.tsx
import { useAuth } from './AuthContext'
import type { Permission } from './permissions'
 
type Props = {
  permission: Permission
  acl?: string[] // present → instance-level; absent → type-level
  children: React.ReactNode
  fallback?: React.ReactNode
}
 
export function Can({ permission, acl, children, fallback = null }: Props) {
  const { can, canOnResource } = useAuth()
  const allowed = acl ? canOnResource(permission, acl) : can(permission)
  return <>{allowed ? children : fallback}</>
}

If we pass ACL records, we are doing an instance-level check; if we omit them, we will be performing a type-level check:

// type-level — "can this user create posts at all?"
<Can permission="posts:create">
  <NewPostButton />
</Can>
 
// instance-level — "can this user edit this post?"
<Can permission="posts:edit" acl={post.acl} fallback={<ReadOnlyBadge />}>
  <EditButton />
</Can>

The acl={post.acl} comes from the resource you already fetched.

Route protection

Route protection is the same can() check, applied before a screen renders instead of around a button, for example. The specific implementation details will depend on your router, but the idea is universal: if the user lacks the permission, redirect or render a "not authorized" view.

function RequirePermission({
  permission,
  children,
}: {
  permission: Permission
  children: React.ReactNode
}) {
  const { can } = useAuth()
  if (!can(permission)) return <Navigate to="/403" replace />
  return <>{children}</>
}
 
// wrap a protected page
<RequirePermission permission="users:manage">
  <UsersAdminPage />
</RequirePermission>

Routes are almost always a type-level concern, you gate the whole screen on a role permission. Instance-level checks live inside the page, on the individual records it shows.

Server-side enforcement

Everything above is about the user experience: hiding buttons the user can't use, keeping them off pages they can't access. But none of it keeps our application secure. A determined user can call your API directly, ignore your UI entirely, and send whatever request they like.

The server checks every permission on every request that mutates or fetches data. The client check and the server check perform the same validation, both just use the same permission registry to do it. The client check exists so honest users get a good user experience; the server check is there because not everyone is honest.

CASL

It's worth mentioning that there are libraries to help get the same job done without having to define or write the helpers and components all by yourself. Whether you choose to use a library or create a custom solution will depend on the needs of your project.

CASL is a popular library that can be useful when implementing this type of access control in your applications, you can find more in the official documentation.