Authorization: Roles, Permissions, and the Real Security Boundary
Learn how role- and permission-based authorization models work, how to apply them in the UI for better UX with feature flags and conditional rendering, and why frontend authorization must never be treated as a real security boundary — the API is.
Once a user is authenticated (we know who they are), authorization decides what they're allowed to do. In a React app, authorization shows up constantly — hiding an 'Edit' button from users who can't edit, graying out an admin panel link, rolling out a feature to 10% of users — but every one of those UI decisions is a convenience layer on top of a security boundary that must live somewhere else entirely: the server.
Roles and permissions
A role is a named bundle of permissions assigned to a user — 'admin,' 'editor,' 'viewer' — that makes authorization decisions easier to reason about and assign in bulk, since you grant a role rather than enumerating individual permissions per user. Role-based access control (RBAC) works well when access patterns cluster naturally into a small number of well-defined job functions.
A permission is a finer-grained statement of what's allowed — 'can delete posts,' 'can invite users,' 'can view billing' — and permission-based (or attribute-based) authorization checks specific permissions directly rather than inferring them from a role name. This scales better than pure RBAC when access needs are more granular than a handful of roles can express (e.g. 'can edit only posts they authored' isn't fully captured by a role alone).
Many real systems combine both: roles as a convenient default bundle of permissions, with the ability to grant or revoke individual permissions on top of a role for exceptions. Either way, the authoritative list of a user's roles/permissions must be established and verified server-side — the client should treat whatever roles/permissions it receives from the server as data to render around, not as something it computes or grants itself.
Feature flags and conditional UI authorization
A feature flag is a runtime toggle that controls whether a piece of functionality is visible or active for a given user, independent of their role — used for gradual rollouts, A/B tests, or enabling paid-tier-only features. Feature flags are typically fetched from a config/flags service alongside auth state and combined with role/permission checks to decide what to render.
UI authorization is the practice of conditionally rendering elements based on the current user's roles, permissions, and flags — hiding a 'Delete' button from a viewer, showing an 'Upgrade' banner instead of a premium chart for a free-tier user, or fully hiding an admin nav link from non-admins. Done well, this is purely about user experience: it prevents users from seeing controls they can't use and reduces confusion, and it should be implemented as simple conditional rendering driven by role/permission/flag state, not as ad hoc scattered checks.
It's tempting to think that hiding a button also secures the action behind it, but hiding UI only prevents a well-behaved browser user from casually clicking it — it does nothing to stop a request crafted directly against the API (via devtools, curl, or a modified client), which is precisely why UI authorization must always be paired with the same check enforced on the server.
Why frontend authorization is never the real boundary
Every line of JavaScript running in a user's browser is, in principle, fully visible and modifiable by that user — they can open devtools, edit application state, monkey-patch functions, or bypass your bundle entirely and call your API directly with any tool that speaks HTTP. This means any authorization check performed only in React — 'if user.role === admin, render this' — can be defeated by a motivated user, because the check itself, and everything protecting it, runs on a machine the user fully controls.
The API, by contrast, runs on infrastructure the user does not control. When the server independently verifies 'does this authenticated user have permission to perform this action on this resource' before executing it, that check cannot be bypassed by manipulating the client, because there is no client-side code involved in enforcing it — it's a decision made entirely on the server, using the server's own record of the user's roles and permissions.
This is why frontend authorization must be understood as strictly additive to backend authorization, never a substitute for it: build it for a better user experience (hide what they can't use, guide them toward what they can), but build backend authorization as if the frontend check didn't exist at all, because from a security standpoint, it effectively doesn't.
A reusable permission-gated component
This component only controls what renders — it must never be treated as protecting the deletePost action itself, since the API must independently verify 'posts:delete' before actually deleting anything. permissions comes from the server (typically alongside the authenticated user object), never computed or granted by the client.
type AuthzState = { roles: string[]; permissions: string[] };
const AuthzContext = createContext<AuthzState>({ roles: [], permissions: [] });
function Can({
permission,
children,
fallback = null,
}: {
permission: string;
children: React.ReactNode;
fallback?: React.ReactNode;
}) {
const { permissions } = useContext(AuthzContext);
return permissions.includes(permission) ? <>{children}</> : <>{fallback}</>;
}
// usage
<Can permission="posts:delete">
<button onClick={deletePost}>Delete post</button>
</Can>Code Examples
Hiding the button when canDelete is false is purely a UX improvement. The real protection is the server returning 403 Forbidden if it independently determines the user lacks the posts:delete permission — that check happens regardless of whether the button was ever rendered. Expected result: A non-permitted user never sees the delete button at all; if they somehow trigger the DELETE request anyway (e.g. via devtools), the server rejects it with 403 regardless.
if (res.status === 403) — the client handles a server-side denial, proving the server is the one actually enforcing the rule.
The role check gates who sees billing information at all, while the feature flag independently controls which UI variant is shown to those who do — two different, composable authorization-adjacent concerns. Expected result: Non-admins see nothing; admins see either the new or legacy billing dashboard depending on their flag assignment.
function DeletePostButton({ postId }: { postId: string }) {
const { permissions } = useContext(AuthzContext);
const canDelete = permissions.includes('posts:delete');
const handleDelete = async () => {
// the server independently re-checks permission — this call is not trusted
// to succeed just because the button was visible
const res = await fetch(`/api/posts/${postId}`, {
method: 'DELETE',
credentials: 'include',
});
if (res.status === 403) {
alert('You do not have permission to delete this post.');
}
};
if (!canDelete) return null; // UX: don't show a control the user can't use
return <button onClick={handleDelete}>Delete post</button>;
}function BillingSection() {
const { roles } = useContext(AuthzContext);
const { flags } = useFeatureFlags(); // fetched from a flags service
const isAdmin = roles.includes('admin');
const newBillingUiEnabled = flags['new-billing-ui'] === true;
if (!isAdmin) return null; // role check: only admins see billing at all
return newBillingUiEnabled ? <NewBillingDashboard /> : <LegacyBillingDashboard />;
}Frontend vs. backend authorization
Frontend checks decide what to show; only backend checks decide what's actually allowed to happen. Treat them as serving completely different purposes.
Frontend authorization
↓
Better user experience
Backend authorization
↓
Actual security boundaryImportant Rules
Always source roles and permissions from the server as data — never let the client compute, infer, or self-assign what a user is allowed to do.
Treat every frontend authorization check (conditional rendering, disabled buttons, hidden routes) as a UX affordance only — assume a motivated user can bypass all of it.
Enforce every authorization decision independently on the server/API, using the server's own record of the user's identity and permissions, regardless of what the client claims or what UI was shown.
Design feature flags and role checks as composable, separate concerns — a flag controls variant/rollout, a role/permission controls access — rather than conflating the two.
Where this shows up
Admin panels, multi-tenant SaaS dashboards, and any app with paid tiers all rely on this two-layer model: conditional rendering and feature flags shape a clean, role-appropriate UI, while every underlying API endpoint independently re-verifies permissions — a security review that finds authorization enforced only in the frontend treats it as a critical vulnerability, not a stylistic choice.
// hiding the button and assuming that's sufficient protection {user.role === 'admin' && <button onClick={deleteAllUsers}>Delete all users</button>} // deleteAllUsers calls DELETE /api/users with no further server-side role check
{user.role === 'admin' && <button onClick={deleteAllUsers}>Delete all users</button>} // AND, on the server: // DELETE /api/users handler independently verifies req.user.role === 'admin' before executing
Hiding the button prevents casual misuse through the normal UI, but a user can call DELETE /api/users directly with devtools or curl; without an independent server-side role check, the frontend condition provides no actual protection.
[multiple-choice] Why can't hiding a button in the UI be relied upon as the actual security control for the action it triggers? A. Hidden buttons still render invisibly and can be clicked accidentally B. Users fully control their own browser and can bypass client-side code entirely, calling the underlying API directly regardless of what the UI shows C. React does not support conditional rendering D. Hidden elements are automatically sent to the server anyway
B. Users fully control their own browser and can bypass client-side code entirely, calling the underlying API directly regardless of what the UI shows — Because the browser and its JavaScript are entirely under the user's control, any check performed only in the client (including what's rendered) can be bypassed by calling the API directly, so it provides no real protection on its own.
[multiple-choice] According to the frontend-vs-backend authorization diagram, what is the correct role of frontend authorization? A. It is the primary and sufficient security boundary B. It provides a better user experience by shaping what's shown, while the backend remains the actual security boundary C. It replaces the need for backend checks once implemented correctly D. It has no purpose and should be avoided
B. It provides a better user experience by shaping what's shown, while the backend remains the actual security boundary — Frontend authorization improves the experience by hiding controls a user can't use, but it is never the security boundary itself — that role belongs entirely to backend authorization, which cannot be bypassed by manipulating the client.
[multiple-choice] What is the main difference between role-based and permission-based authorization? A. They are identical concepts with different names B. Roles bundle a set of permissions under one name for easier bulk assignment, while permissions express finer-grained, individually checkable capabilities C. Permission-based authorization does not require server-side data D. Roles can only be used for admin users
B. Roles bundle a set of permissions under one name for easier bulk assignment, while permissions express finer-grained, individually checkable capabilities — A role groups related permissions under a convenient label (making bulk assignment simple), while permission-based checks operate on individual, finer-grained capabilities directly, which scales better for access patterns too granular for a small set of roles to express.
Write a withPermission higher-order route wrapper (similar to ProtectedRoute) that takes a required permission string, redirects unauthenticated users to /login, and redirects authenticated-but-unauthorized users to /forbidden, while emphasizing in a code comment that the wrapped page's own data-fetching must still be enforced server-side.
function WithPermission({ permission, children, }: { permission: string; children: React.ReactNode; }) { const { user, isLoading: authLoading } = useContext(AuthContext); const { permissions } = useContext(AuthzContext); if (authLoading) return <Spinner />; if (!user) return <Navigate to="/login" replace />; if (!permissions.includes(permission)) return <Navigate to="/forbidden" replace />; // NOTE: this only controls rendering. Every API call the wrapped page makes // must independently re-verify this permission server-side — this wrapper // is not a substitute for that check. return <>{children}</>; }
Layer the permission check after the existing authenticated-user check, and keep the redirect targets distinct (/login vs /forbidden) so the two failure modes are visibly different to the user.