Grep any codebase old enough to have real customers for role === or .includes('admin') and you'll usually find a lot of hits. Each one made sense the day it was written. Together they mean nobody can answer "who can currently delete a customer record" without reading every controller, middleware, and background job in the system. That question needs a single, findable answer.
The Problem With Permission Logic That's Everywhere
Scattered checks drift apart over time in ways that are easy to miss individually. One controller checks user.role === 'admin', another checks user.role === 'admin' || user.role === 'owner', and a background job that runs the same logical operation forgot the owner case entirely. Nobody decided this should be inconsistent. It happened because there was no single place enforcing consistency in the first place.
The fix isn't a style guide asking developers to remember to check both roles everywhere. It's removing the ability to write the check any other way, by making the permission function the only path to the answer.
What a Centralized Check Actually Looks Like
The shape is simple: one function, something like can(user, action, resource), that every mutating endpoint and background job calls before doing anything. It's the only piece of code that queries roles or permissions tables directly. Controllers, jobs, and API middleware call it and get back true or false. They never reconstruct the logic themselves.
function can(user, action, resource) {
const permissions = resolvePermissions(user, resource?.scope);
return permissions.has(action);
}
That resolvePermissions call is where roles get expanded into an actual permission set, cached per request, and scoped to whatever resource or tenant is relevant. Everything downstream of it is a boolean check, which is exactly the kind of code that's easy to review and easy to test.
Why This Also Fixes Your Test Coverage
When permission logic is centralized, you can write one comprehensive test suite against the can function covering every role and every action, instead of trying to test the same logic reimplemented slightly differently in a dozen controllers. A single well-tested function is far more trustworthy than fifty untested inline conditionals that each look reasonable on their own.
It also makes code review meaningfully easier. A reviewer only needs to check "does this endpoint call can() with the right action" rather than re-verifying an entire access control expression written inline. That's a much smaller cognitive load, and it catches missing checks faster because a missing can() call stands out in a diff in a way a subtly wrong inline condition doesn't.
Handling the Edge Cases Without Breaking the Pattern
Real systems have exceptions: a support tool that needs impersonation, a system account that bypasses normal scoping, a legacy integration with its own auth path. The temptation is to special-case these outside the centralized function. Resist it. Model them as explicit permissions or a distinct user type that flows through the same can() call, even if the underlying resolution logic differs.
This matters because the moment you allow a second legitimate path to bypass the central check, you've reopened the door to the scattered-logic problem you were trying to close. OWASP's access control guidance makes the same point from a security angle: every additional code path that can grant access is another place a review has to look, and every one you can eliminate is a real reduction in risk.
This Isn't a New Idea, Just a Neglected One
Centralizing access decisions is exactly what role-based access control was formalized to do in the first place, long before most current web frameworks existed. Wikipedia's overview of role-based access control lays out the same three-part separation this piece has been describing: users, roles, and permissions, kept distinct so any one of them can change without touching the other two. Scattered inline checks aren't a different architecture. They're the same underlying problem the formal model exists to solve, just implemented without the structure that makes it maintainable.
Hosted identity platforms lean on the same principle for the same reason. Auth0 and comparable providers ship centralized role and permission management as a core feature, not an afterthought, because "one function decides access, everything else asks it" turns out to be the pattern nearly every team converges on eventually, whether they build it themselves or buy it.
The Caching Question Nobody Asks Until It's a Problem
Once you centralize permission resolution, the next question is almost always performance, because that one function now runs on nearly every request instead of being scattered across a handful of endpoints. Resist the urge to skip caching entirely just to keep the implementation simple. A permission resolution that hits the database on every request works fine in a demo and falls over under real traffic.
Cache the resolved permission set per user, scoped by whatever resource context matters, with a short TTL and an explicit invalidation hook fired on role or membership changes. That combination handles the overwhelming majority of cases correctly, and it's a much smaller amount of code than most teams expect once the resolution logic itself is already centralized in one place.
What to Do When Frameworks Fight You On This
Some frameworks encourage scattering authorization logic by design, with decorators or middleware attached directly to route definitions rather than a single shared function. That pattern isn't inherently wrong, but it's easy to let it drift into inconsistency if each route's decorator reimplements the check slightly differently instead of all calling the same underlying function.
The fix isn't abandoning the framework's conventions. It's making sure every decorator or middleware is a thin wrapper around the same centralized can() call, never an independent reimplementation. Whatever mechanism your framework prefers for attaching authorization to a route, treat it purely as plumbing that delegates to one shared decision point, the same principle MDN's documentation on web security applies broadly across authentication and authorization concerns regardless of framework.
Where to Start on an Existing Codebase
You don't need a rewrite. Grep for the scattered patterns first, build the centralized can() function alongside them, and migrate one controller at a time to call it instead of its inline check. Keep the old checks working as a fallback until you're confident the new function covers every case the old logic did.
If you're designing the underlying roles and permissions data model from scratch rather than retrofitting one, our full guide on building a role-based permissions system walks through the schema, scoping, and caching strategy that this centralized function sits on top of. 137Foundry has done this migration on production systems where the scattered checks had been accumulating for years, and it's almost always more tractable than it looks from the outside once you have one function to migrate toward.
The goal isn't perfection on day one. It's making sure there's exactly one place where "can this user do this" gets decided, so that answer stays trustworthy as the system grows.
That single place is also the piece of the codebase most worth over-investing in relative to its size. It's rarely more than a couple hundred lines, but every feature your product ships that touches access control runs through it, which makes it one of the highest-leverage places to put careful review, thorough tests, and clear naming.
Top comments (0)