Enterprise SaaS applications demand more than a binary authenticated/unauthenticated gate. A billing manager shouldn't see the user management panel. An editor shouldn't trigger destructive database operations. A viewer shouldn't even see the button that does it.
This guide covers how to architect a fine-grained, permission-based access control system in React that scales from a three-person startup to a multi-tenant enterprise product. We'll build every layer: the permission model, the React context, declarative guard components, protected routes, API-layer enforcement, and testing strategies.
permissions array to the client.hasPermission, hasAnyPermission, and hasAllPermissions checkers, memoized to prevent unnecessary re-renders.<Guard> component to show or hide UI elements based on those permissions.<ProtectedRoute> wrapper that redirects unauthorized users.Most RBAC tutorials start with code like this:
tsx
// Fragile: breaks every time your role taxonomy changes
if (user.role === 'admin') {
return <DeleteButton />;
}
This creates a direct coupling between your UI logic and your organizational hierarchy. When product requirements add a "Billing Manager" role next quarter, you'll grep through every component that checks for 'admin' and decide whether billing managers should also see that button.
The fix is to introduce a layer of indirection. Roles map to permissions. UI components check permissions.
Role: Admin → permissions: ['posts:', 'users:', 'billing:'] Role: Editor → permissions: ['posts:create', 'posts:update', 'posts:publish'] Role: Viewer → permissions: ['posts:read'] Role: BillingMgr → permissions: ['billing:read', 'billing:write', 'invoices:export']
Adding a new role becomes a configuration change, not a code change. Your components never reference role strings directly.
Design your permission strings as resource:action pairs. This convention is borrowed from systems like AWS IAM and Casbin, and it scales cleanly.
typescript
// permissions.ts
export const PERMISSIONS = {
POSTS_CREATE: 'posts:create',
POSTS_READ: 'posts:read',
POSTS_UPDATE: 'posts:update',
POSTS_DELETE: 'posts:delete',
POSTS_PUBLISH: 'posts:publish',
USERS_INVITE: 'users:invite',
USERS_REMOVE: 'users:remove',
USERS_EDIT_ROLE: 'users:edit-role',
BILLING_READ: 'billing:read',
BILLING_WRITE: 'billing:write',
INVOICES_EXPORT: 'invoices:export',
SETTINGS_MANAGE: 'settings:manage',
} as const;
export type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
Using a constants object with as const gives you autocomplete and type safety. Every permission check in your codebase references these constants, so a typo becomes a compile error instead of a silent authorization failure.
For admin-level roles, supporting wildcard patterns avoids maintaining an ever-growing list:
typescript
// permission-utils.ts
export function matchesPermission(
userPermissions: string[],
required: string
): boolean {
return userPermissions.some((p) => {
if (p === required) return true;
// Wildcard: 'posts:' matches 'posts:delete'
if (p.endsWith(':')) {
const prefix = p.slice(0, -1); // 'posts:'
return required.startsWith(prefix);
}
// Super-admin wildcard
if (p === '') return true;
return false;
});
}
Your API should return permissions directly on the user object. Whether you compute them server-side from role assignments, pull them from a policy engine like Permify or OpenFGA, or hardcode them during development, the frontend always receives a flat array. If you document that integration as product-led content, lead with the permission model before the vendor SDK.
json
{
"id": "usr_9921",
"name": "Fimber Elemuwa",
"role": "Editor",
"permissions": ["posts:create", "posts:update", "posts:publish"],
"orgId": "org_1134"
}
The role field remains useful for display purposes (showing "Editor" in the profile badge), but no UI branching logic should depend on it.
With the permission model and user shape defined, the next step is wiring these permissions into React's component tree so any component can check access.
The auth context serves two purposes: store the authenticated user and expose a permission-checking function that any component can call.
typescript
// AuthContext.tsx
import React, { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react';
import { matchesPermission } from './permission-utils';
interface User {
id: string;
name: string;
role: string;
permissions: string[];
orgId: string;
}
interface AuthContextType {
user: User | null;
isLoading: boolean;
hasPermission: (perm: string) => boolean;
hasAnyPermission: (...perms: string[]) => boolean;
hasAllPermissions: (...perms: string[]) => boolean;
}
const AuthContext = createContext<AuthContextType>({
user: null,
isLoading: true,
hasPermission: () => false,
hasAnyPermission: () => false,
hasAllPermissions: () => false,
});
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const fetchUser = async () => {
try {
const res = await fetch('/api/auth/me');
if (res.ok) {
const data = await res.json();
setUser(data.user);
}
} catch (err) {
console.error('Auth fetch failed:', err);
} finally {
setIsLoading(false);
}
};
fetchUser();
}, []);
const hasPermission = useCallback(
(perm: string) => {
if (!user) return false;
return matchesPermission(user.permissions, perm);
},
[user]
);
const hasAnyPermission = useCallback(
(...perms: string[]) => perms.some((p) => hasPermission(p)),
[hasPermission]
);
const hasAllPermissions = useCallback(
(...perms: string[]) => perms.every((p) => hasPermission(p)),
[hasPermission]
);
const value = useMemo(
() => ({ user, isLoading, hasPermission, hasAnyPermission, hasAllPermissions }),
[user, isLoading, hasPermission, hasAnyPermission, hasAllPermissions]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
export const useAuth = () => useContext(AuthContext);
Three things worth noting:
1. useCallback on permission checkers prevents child components from re-rendering when unrelated state changes in the provider. Without this, every component calling useAuth() re-renders on every provider state update.
2. useMemo on the context value prevents the provider from creating a new object reference on every render, which would invalidate every consumer.
3. hasAnyPermission and hasAllPermissions handle real-world cases where a single button requires multiple permissions, or a section should be visible to anyone with at least one of several permissions.
The context gives every component access to permission checks. The next piece is a reusable wrapper that translates those checks into conditional rendering.
A declarative <Guard> component replaces scattered if statements throughout your JSX.
tsx
// Guard.tsx
import React from 'react';
import { useAuth } from './AuthContext';
interface GuardProps {
/* Single permission required /
permission?: string;
/* Show if user has ANY of these permissions /
anyOf?: string[];
/* Show if user has ALL of these permissions /
allOf?: string[];
/* Rendered when the user lacks the required permission(s) /
fallback?: React.ReactNode;
children: React.ReactNode;
}
export const Guard: React.FC<GuardProps> = ({
permission,
anyOf,
allOf,
fallback = null,
children,
}) => {
const { hasPermission, hasAnyPermission, hasAllPermissions } = useAuth();
let authorized = false;
if (permission) {
authorized = hasPermission(permission);
} else if (anyOf) {
authorized = hasAnyPermission(...anyOf);
} else if (allOf) {
authorized = hasAllPermissions(...allOf);
}
if (!authorized) return <>{fallback}</>;
return <>{children}</>;
};
Single permission gate:
tsx
<Guard permission={PERMISSIONS.POSTS_DELETE}>
<button className="bg-red-600 text-white px-4 py-2 rounded">
Delete Post
</button>
</Guard>
Show a section if the user has any content-editing permission:
tsx
<Guard anyOf={[PERMISSIONS.POSTS_CREATE, PERMISSIONS.POSTS_UPDATE]}>
<EditorToolbar />
</Guard>
Require multiple permissions simultaneously:
tsx
<Guard
allOf={[PERMISSIONS.BILLING_READ, PERMISSIONS.INVOICES_EXPORT]}
fallback={<UpgradeBanner />}
>
<InvoiceExportPanel />
</Guard>
Disable instead of hide (useful when you want users to know a feature exists):
tsx
const { hasPermission } = useAuth();
<button
disabled={!hasPermission(PERMISSIONS.POSTS_PUBLISH)}
title={
hasPermission(PERMISSIONS.POSTS_PUBLISH)
? 'Publish this post'
: 'You need publishing permissions'
}
className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-40 disabled:cursor-not-allowed"
>
Publish
</button>
Guards cover individual elements within a page. However, some pages should be entirely inaccessible to certain users, which requires enforcement at the routing layer.
tsx
// ProtectedRoute.tsx
import { Navigate, Outlet } from 'react-router-dom';
import { useAuth } from './AuthContext';
interface ProtectedRouteProps {
permission?: string;
anyOf?: string[];
redirectTo?: string;
}
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
permission,
anyOf,
redirectTo = '/unauthorized',
}) => {
const { user, isLoading, hasPermission, hasAnyPermission } = useAuth();
if (isLoading) {
return <LoadingSkeleton />;
}
if (!user) {
return <Navigate to="/login" replace />;
}
const authorized = permission
? hasPermission(permission)
: anyOf
? hasAnyPermission(...anyOf)
: true;
if (!authorized) {
return <Navigate to={redirectTo} replace />;
}
return <Outlet />;
};
Wire it into your router configuration:
tsx
// AppRouter.tsx
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
const router = createBrowserRouter([
{
path: '/',
element: <AppLayout />,
children: [
{ index: true, element: <Dashboard /> },
// Any authenticated user can access
{
element: <ProtectedRoute />,
children: [
{ path: 'profile', element: <Profile /> },
],
},
// Only users with billing permissions
{
element: <ProtectedRoute permission={PERMISSIONS.BILLING_READ} />,
children: [
{ path: 'billing', element: <BillingDashboard /> },
{ path: 'billing/invoices', element: <InvoiceList /> },
],
},
// Content management section
{
element: (
<ProtectedRoute
anyOf={[PERMISSIONS.POSTS_CREATE, PERMISSIONS.POSTS_UPDATE]}
/>
),
children: [
{ path: 'posts', element: <PostList /> },
{ path: 'posts/new', element: <PostEditor /> },
{ path: 'posts/:id/edit', element: <PostEditor /> },
],
},
// Admin-only
{
element: <ProtectedRoute permission={PERMISSIONS.USERS_INVITE} />,
children: [
{ path: 'admin/users', element: <UserManagement /> },
],
},
{ path: 'unauthorized', element: <UnauthorizedPage /> },
],
},
]);
export const AppRouter = () => <RouterProvider router={router} />;
Your sidebar shouldn't show links to pages the user can't access. Build a navigation config that declares required permissions:
tsx
// nav-config.ts
interface NavItem {
label: string;
path: string;
icon: React.ComponentType;
permission?: string;
anyOf?: string[];
}
export const NAV_ITEMS: NavItem[] = [
{ label: 'Dashboard', path: '/', icon: HomeIcon },
{ label: 'Posts', path: '/posts', icon: FileTextIcon, permission: PERMISSIONS.POSTS_READ },
{ label: 'Billing', path: '/billing', icon: CreditCardIcon, permission: PERMISSIONS.BILLING_READ },
{ label: 'Team', path: '/admin/users', icon: UsersIcon, permission: PERMISSIONS.USERS_INVITE },
{ label: 'Settings', path: '/settings', icon: SettingsIcon, permission: PERMISSIONS.SETTINGS_MANAGE },
];
tsx
// Sidebar.tsx
export const Sidebar: React.FC = () => {
const { hasPermission, hasAnyPermission } = useAuth();
const visibleItems = NAV_ITEMS.filter((item) => {
if (!item.permission && !item.anyOf) return true;
if (item.permission) return hasPermission(item.permission);
if (item.anyOf) return hasAnyPermission(...item.anyOf);
return false;
});
return (
<nav>
{visibleItems.map((item) => (
<NavLink key={item.path} to={item.path}>
<item.icon />
<span>{item.label}</span>
</NavLink>
))}
</nav>
);
};
Route guards and navigation filtering handle the "what" of access control. But both depend on permissions being loaded before the first render, which introduces a timing problem.
Without a loading gate, the app renders briefly with default (unauthorized) state before permissions arrive, causing UI elements to flicker in and out. The fix is an AuthGate component that blocks the render tree until the permission fetch completes:
tsx
// App.tsx
export const App: React.FC = () => {
return (
<AuthProvider>
<AuthGate>
<AppRouter />
</AuthGate>
</AuthProvider>
);
};
// AuthGate.tsx
const AuthGate: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { isLoading } = useAuth();
if (isLoading) {
return <FullPageSpinner />; // or a skeleton layout
}
return <>{children}</>;
};
This loading gate guarantees that no route or component renders until the permission data is available. The app transitions from a loading state directly to the fully authorized view, with no intermediate flicker.
Everything covered so far lives on the client. That layer shapes the user experience, but it cannot enforce security on its own.
Frontend RBAC controls visibility and user experience. It does not enforce security. Every permission check in your React code must have a corresponding server-side check.
The reason is straightforward: any motivated user can open browser devtools, modify local state, or call your API directly. Client-side checks exist to build a good interface, not to protect data.
typescript
// middleware/authorize.ts
export function requirePermission(permission: string) {
return (req: Request, res: Response, next: NextFunction) => {
const user = req.user; // populated by auth middleware
if (!user) {
return res.status(401).json({ error: 'Unauthenticated' });
}
if (!matchesPermission(user.permissions, permission)) {
return res.status(403).json({
error: 'Forbidden',
required: permission,
});
}
next();
};
}
// routes/posts.ts
router.delete(
'/posts/:id',
requirePermission('posts:delete'),
async (req, res) => {
await postService.delete(req.params.id);
res.status(204).send();
}
);
When server-side checks reject a request, your frontend should handle it gracefully:
typescript
// api-client.ts
const apiClient = {
async request<T>(url: string, options?: RequestInit): Promise<T> {
const res = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
});
if (res.status === 403) {
// Permission was revoked or client state is stale
throw new PermissionError('You no longer have access to this action');
}
if (!res.ok) {
throw new ApiError(res.status, await res.text());
}
return res.json();
},
};
Pair this with an error boundary or toast notification so users see a clear message instead of a silent failure.
The patterns above work for single-workspace applications. Multi-tenant SaaS products add another dimension: the same user can hold different roles across different organizations.
Structure the user model to include per-organization membership data:
json
{
"id": "usr_9921",
"name": "Fimber Elemuwa",
"activeOrgId": "org_1134",
"memberships": [
{
"orgId": "org_1134",
"role": "Admin",
"permissions": ["posts:", "users:", "billing:"]
},
{
"orgId": "org_5567",
"role": "Viewer",
"permissions": ["posts:read"]
}
]
}
Your auth context resolves permissions based on the active organization:
typescript
const activePermissions = useMemo(() => {
if (!user) return [];
const membership = user.memberships.find(
(m) => m.orgId === user.activeOrgId
);
return membership?.permissions ?? [];
}, [user]);
When the user switches organizations, the permission set updates and the entire UI re-evaluates what's visible and accessible.
Whether single-tenant or multi-tenant, permissions can become stale during an active session. An admin might revoke access, or a user might upgrade their plan. The frontend needs a strategy for staying in sync.
Decide how stale you're willing to let permissions get, then pick the refresh strategy that matches.
typescript
useEffect(() => {
const interval = setInterval(async () => {
try {
const res = await fetch('/api/auth/me');
if (res.ok) {
const data = await res.json();
setUser(data.user);
}
} catch {
// Silently retry on next interval
}
}, 5 60 1000); // Refresh every 5 minutes
return () => clearInterval(interval);
}, []);
For more responsive updates, listen for server-sent events or WebSocket messages:
typescript
useEffect(() => {
const eventSource = new EventSource('/api/auth/permissions-stream');
eventSource.addEventListener('permissions-updated', (event) => {
const updated = JSON.parse(event.data);
setUser((prev) => prev ? { ...prev, permissions: updated.permissions } : null);
});
return () => eventSource.close();
}, []);
With the full RBAC system in place, the final engineering concern is verifying that every permission boundary works as expected.
tsx
// Guard.test.tsx
import { render, screen } from '@testing-library/react';
import { AuthContext } from './AuthContext';
import { Guard } from './Guard';
function renderWithPermissions(permissions: string[], ui: React.ReactElement) {
const user = { id: '1', name: 'Test', role: 'Test', permissions, orgId: 'org_1' };
return render(
<AuthContext.Provider
value={{
user,
isLoading: false,
hasPermission: (p) => matchesPermission(permissions, p),
hasAnyPermission: (...ps) => ps.some((p) => matchesPermission(permissions, p)),
hasAllPermissions: (...ps) => ps.every((p) => matchesPermission(permissions, p)),
}}
>
{ui}
</AuthContext.Provider>
);
}
test('renders children when user has the required permission', () => {
renderWithPermissions(['posts:delete'], (
<Guard permission="posts:delete">
<button>Delete</button>
</Guard>
));
expect(screen.getByText('Delete')).toBeInTheDocument();
});
test('renders fallback when user lacks the required permission', () => {
renderWithPermissions(['posts:read'], (
<Guard permission="posts:delete" fallback={<span>No access</span>}>
<button>Delete</button>
</Guard>
));
expect(screen.queryByText('Delete')).not.toBeInTheDocument();
expect(screen.getByText('No access')).toBeInTheDocument();
});
test('wildcard permission grants access', () => {
renderWithPermissions(['posts:'], (
<Guard permission="posts:delete">
<button>Delete</button>
</Guard>
));
expect(screen.getByText('Delete')).toBeInTheDocument();
});
tsx
test('redirects to /unauthorized when permission is missing', () => {
renderWithPermissions([], (
<MemoryRouter initialEntries={['/billing']}>
<Routes>
<Route element={<ProtectedRoute permission="billing:read" />}>
<Route path="/billing" element={<div>Billing Page</div>} />
</Route>
<Route path="/unauthorized" element={<div>Access Denied</div>} />
</Routes>
</MemoryRouter>
));
expect(screen.getByText('Access Denied')).toBeInTheDocument();
});
Cover these scenarios systematically:
posts: elements visible*)Permify provides a Google Zanzibar-inspired authorization service. You define relationships between resources and subjects, then query the engine to check access. Your frontend still receives a flat permission array, but the computation happens in Permify's engine rather than your API code. For editorial patterns that introduce tools like this without losing developer trust, see How to Write Product-Led Content That Engineers Actually Respect.
OpenFGA (open-source, originally from Auth0) follows a similar relationship-based model. You define an authorization model, write relationship tuples, and query with check requests.
Cerbos takes a policy-as-code approach, where you define authorization policies in YAML and deploy them alongside your application.
In all cases, the integration point stays the same: your /api/auth/me endpoint queries the policy engine, resolves the user's permissions, and returns the flat array your frontend expects. The React code in this guide works identically regardless of whether permissions come from a database column, a YAML file, or a distributed authorization service.
Before shipping, consider the runtime cost of the RBAC layer itself.
Permission array size. For most applications, a user has fewer than 50 permissions. Array includes() and some() checks at this scale take nanoseconds. If your permission set grows into the hundreds (multi-tenant systems with granular resource-level permissions), convert the array to a Set in the auth context for O(1) lookups.
Context re-renders. The useMemo wrapper on the context value ensures consumers only re-render when the user object or loading state actually changes. If you split your context into separate providers (one for user data, one for permission utilities), you can further reduce unnecessary renders in large component trees.
Bundle impact. The entire RBAC system described here adds roughly 2KB gzipped to your bundle. The permission constants, context, guard component, and utility functions are all lightweight. Avoid pulling in heavy authorization libraries on the client; keep the logic thin and delegate complexity to the server.
The full architecture has five layers:
1. Permission constants (permissions.ts): typed, centralized, referenced everywhere.
2. Auth context (AuthContext.tsx): fetches the user, exposes memoized permission checkers.
3. Guard component (Guard.tsx): declarative show/hide for any JSX element.
4. Protected routes (ProtectedRoute.tsx): prevents unauthorized page access with proper redirects.
5. Server enforcement: every API endpoint validates permissions independently of the client.
The client controls the experience. The server controls the access. Both check the same permission strings, and neither trusts the other.