You hid the “Delete” button behind if (user.isAdmin). Then you did the same thing in the toolbar. And the context menu. And a route. Six months later a new role appears, the rules shift, and you are grepping the codebase for every place you scattered that check - praying you didn’t miss one.
There is a much cleaner way to express “only these people see this.” But it comes bundled with a misconception so common, and so dangerous, that I want to deal with it head-on before you ship a single guard. Let’s build the pattern - and then talk about what it absolutely is not.
The problem: access logic, smeared everywhere
Inline permission checks feel harmless the first time. The damage is cumulative:
- The same rule gets copy-pasted across components, each subtly different.
- Changing a rule means hunting down every copy.
- There is no single place to look and answer “who can do what?”
- Testing is a nightmare, because the logic is tangled into unrelated UI.
This is duplicated business logic wearing a UI costume. And like all duplicated logic, it drifts.
The pattern: one declarative guard
The permission guard pattern collapses all of that into a single component whose only job is to answer one question - is this user allowed to see this? - and render accordingly.
// One component. One responsibility.
function PermissionGuard({ required, fallback = null, children }) {
const { can } = usePermissions();
return can(required) ? children : fallback;
}
Now the intent reads cleanly at every call site, and the how lives in exactly one place:
<PermissionGuard required="invoice:delete">
<DeleteButton />
</PermissionGuard>
<PermissionGuard required="reports:view" fallback={<UpgradePrompt />}>
<RevenueDashboard />
</PermissionGuard>
The usePermissions hook (or store, or service - pick your framework’s idiom) is the single source of truth. It reads the user’s permissions once - typically from verified claims in a JWT - and exposes a can() check:
function can(required) {
// default-deny: no permissions loaded => no access
if (!permissions) return false;
return permissions.includes(required);
}
Two design choices in there matter more than they look:
- Check permissions, not role names.
can("invoice:delete")survives a reorg;if (role === "admin")breaks the moment “admin” splits into three roles. Roles map to permissions; your UI should ask about the capability, not the label. - Fail closed. If permissions haven’t loaded or something is unclear, the answer is no. Default-deny is the only safe default.
Guarding whole routes, not just buttons
The same idea scales up to entire pages. Instead of wrapping a button, you wrap a route - checking access before the view ever renders and redirecting if it fails:
<Route
path="/admin"
element={
<PermissionGuard required="admin:access" fallback={<Navigate to="/" />}>
<AdminPage />
</PermissionGuard>
}
/>
Same component, same source of truth, bigger blast radius. One place still owns the rule.
Now the part that gets people breached
Here is the misconception, stated plainly so you can never claim you weren’t warned:
A frontend permission guard is not security. It is user experience.
Everything that runs in a browser can be read, paused, and rewritten by the user. Your can() check, your usePermissions store, the JWT sitting in memory - all of it is on their machine, under their control. A curious user opens dev tools and flips your check to true. A malicious one just calls your API directly and skips your beautiful component entirely.
So what is the guard actually for? It signals. It hides the “Delete” button so a normal user isn’t confused or tempted by an action they can’t take. It routes people to the right place. It makes the product feel coherent. That is genuinely valuable - it is just not enforcement.
Enforcement lives on the server, every time, without exception:
- The client guard hides the delete button.
- The server rejects the delete request if the caller isn’t actually allowed - because it re-checks, with full context about the user and the resource.
Treat client-side authorization as a purely additive layer on top of real server-side checks. The guard is there to help honest users; the server is there to stop everyone else. If you only build one of the two, build the server one.
A quick checklist before you ship it
- Permissions come from verified claims (a validated token), not client state you can spoof.
-
can()checks capabilities, not role strings. - The guard fails closed - unknown means no.
- Every guarded action has a matching server-side check. No exceptions.
- The guard component stays dumb - it decides show/hide, nothing else. No data fetching, no business rules.
The velvet rope and the vault door
Walk up to an exclusive club and you’ll see a velvet rope. It guides the line, it signals “members only,” it keeps the casual crowd from wandering into the VIP room. It shapes behavior beautifully. And it stops exactly no one who is determined - you can step over a velvet rope in a second.
That is your permission guard. It organizes the crowd and sets expectations at a glance. But the thing that actually protects the vault is the locked door and the guard standing at it - your server. Build the velvet rope; your users will thank you for the clarity. Just never, ever mistake it for the lock.