How to Build a Role-Based Permissions System That Doesn't Fall Apart Past Admin and User

Close-up photo of a keycard being held near an electronic access reader

Every product starts with two roles: admin and user. It's simple, it ships fast, and for the first few months it's genuinely the right call. Then a customer asks for a "manager" who can approve expenses but not delete accounts, another wants a "read-only auditor," and suddenly someone is writing if (user.role === 'admin' || user.email === 'ceo@bigclient.com') directly into a controller. That line is the first crack. This guide walks through how to design a role-based permissions system that can absorb those requests without turning your codebase into a maze of special cases.

Close-up photo of a keycard being held near an electronic access reader
Photo by REINER SCT on Pexels

Why Admin and User Stops Working Fast

The two-role model breaks down the moment a customer's org chart doesn't match your enum. Real organizations have people who can view financial reports but not edit them, people who manage one team but not the company, and contractors who need narrow, temporary access. None of that fits into a boolean is_admin flag.

The symptom is usually a growing pile of ad-hoc checks scattered through controllers, background jobs, and API middleware. Each one is reasonable in isolation. Together they mean nobody can answer "who can currently delete a customer record" without grepping the entire codebase. That question needs to be answerable in one place, not reconstructed from a dozen conditionals.

The fix isn't a bigger enum of roles. It's separating three concepts that admin/user quietly merges: who someone is, what actions exist, and which resources those actions apply to.

What a Role-Based Permissions System Actually Needs to Do

A permissions system earns its complexity if it can answer four questions cleanly: can this user perform this action, on this specific resource, right now, and why. The "why" matters more than most teams initially think, because support tickets about access almost always start with "I should be able to see this and I can't."

Concretely, that means the system needs a way to define permissions independent of roles (a permission is "can approve invoices," not "is a manager"), a way to group permissions into roles for convenience, and a way to scope a role to a specific resource or tenant rather than the whole account. Roles are a UI convenience for assigning bundles of permissions in bulk. The permission check itself should never care what a role is called.

Keep the permission names verb-first and resource-specific: invoices.approve, users.invite, reports.export. Vague names like manage_billing tend to accumulate unrelated actions over time until nobody remembers everything they cover.

Roles, Permissions, and Resources: Getting the Data Model Right

The data model that holds up over time usually has four tables: users, roles, permissions, and a join table mapping roles to permissions. A separate table maps users to roles, and critically, that mapping should include an optional resource or scope identifier, not just a user-role pair.

Without scoping, you end up bolting on multi-tenancy later as a second, incompatible permission system that has to be checked alongside the first one. Build the scope column in from day one, even if every current role happens to apply account-wide. It costs almost nothing now and saves a painful migration later.

Resist the urge to make permissions themselves hierarchical or inheritable in the data model. Hierarchy belongs in how you assemble roles from permissions, not in the permissions table. A permission is either granted or it isn't; the complexity should live one layer up, where it's easier to reason about and test.

Designing Permission Checks That Don't Slow Down Every Request

Once the model is right, the check itself needs to be fast and boring: given a user, an action, and optionally a resource, return true or false. That function should be the only place in the codebase that touches the roles or permissions tables directly. Everything else calls it.

Performance matters more than it seems like it should, because permission checks run on nearly every request. Cache the resolved permission set per user per session rather than re-querying the join tables on every call, and invalidate that cache when roles change. A five-minute cache with an explicit invalidation hook on role updates handles the overwhelming majority of cases without introducing stale-permission bugs that are hard to reproduce.

Whiteboard diagram with arrows sketching out a permission hierarchy
Photo by Vanessa Garcia on Pexels

Avoid checking permissions in the view layer only. A button that's hidden because a user lacks a permission is a UX nicety, not a security boundary. Every mutating endpoint needs its own server-side check, independent of whatever the frontend decided to render.

Handling Custom Roles Without Turning Into a No-Code Nightmare

Enterprise customers eventually ask for custom roles: pick any combination of permissions and name it whatever they want. This is the point where teams either build something maintainable or accidentally build a mini permissions-configuration product they now have to support forever.

The safer middle ground is to ship a fixed set of well-named permissions and let customers combine them into custom roles through an admin UI, rather than letting them define new permissions from scratch. New permissions should only ever come from your own code shipping new features, because a permission with no corresponding check anywhere is just a false promise sitting in a database.

Version your default roles the same way you'd version an API. When you add a new permission, decide explicitly which existing default roles inherit it automatically versus which require an admin to opt in. Silently granting new capabilities to existing "admin-like" custom roles is how surprising access shows up in a security audit eighteen months later.

Multi-Tenant and Team-Scoped Permissions

If your product has organizations, teams, or workspaces, permissions almost always need to be scoped below the account level. A user can be an admin of Team A and a regular member of Team B in the same account, and the permission check needs the resource context to resolve that correctly.

The cleanest way to handle this is to always pass an explicit scope into the permission check, even when checking account-wide actions, so the function signature never silently assumes "current account" the way early implementations often do. can(user, 'invoices.approve', { team: teamId }) is unambiguous in a way that can(user, 'invoices.approve') isn't once teams exist.

Watch for the specific bug where a user retains access to a resource after being removed from the team that granted it, because the role assignment lives in a cache or a denormalized field that wasn't invalidated. Scoped permissions need the same cache invalidation discipline as unscoped ones, just triggered by team membership changes instead of role changes.

Auditing and Debugging "Why Can't This User See That"

Server rack with organized cable runs in a data center
Photo by Filipe Freitas on Unsplash

Every permissions system eventually needs an audit trail, and it's much cheaper to build in from the start than to retrofit after a customer asks "who changed my access last Tuesday." Log every role assignment, removal, and permission grant with who made the change and when, in an append-only table separate from the current-state tables.

Build an internal debugging view that shows, for any user, the exact chain of role and scope that resolved into their current permission set. Support teams burn enormous time reconstructing this manually by reading database rows. A single page that says "granted via Team Lead role on Team A, inherited from account-wide Manager role" turns a twenty-minute investigation into ten seconds.

"The permissions bugs that actually hurt teams aren't the ones where access is too open, they're the quiet ones where a legitimate user loses access after an unrelated change and nobody notices until a customer complains." - Dennis Traina, founder of 137Foundry

Migrating From Admin/User to RBAC Without a Big-Bang Rewrite

You don't need to migrate everything at once, and you shouldn't try to. Introduce the new permissions and roles tables alongside the existing admin/user flag, and have the permission-check function fall back to the old flag for any permission that hasn't been migrated yet. This lets you move one feature area at a time.

Start with whichever area is generating the most support tickets or the most one-off conditionals in the code, since that's where the new system pays for itself fastest. Billing and account management are common starting points because they tend to accumulate the most ad-hoc access logic early in a product's life.

Keep the old flag around, but treat it as deprecated the moment the new system covers a feature area, and remove the fallback for that area once you're confident nothing still checks it. A migration that never removes the old path just doubles your maintenance surface instead of replacing it.

Testing Permission Logic So Regressions Don't Ship Quietly

Permission bugs are uniquely bad because they fail silently in both directions. Over-permissive bugs leak data and don't get reported until someone notices something they shouldn't have seen. Under-permissive bugs generate support tickets but rarely get root-caused all the way back to the actual permission check.

Write tests as a matrix, not a list: every role against every protected action, asserting both the allow and deny cases explicitly. It's tempting to only test the happy path where a role can do what it's supposed to, but the denial case is exactly what regresses silently when someone adds a new permission and forgets to gate an endpoint behind it.

Treat any new mutating endpoint without an explicit permission test as a blocked pull request, not a follow-up ticket. The cost of writing that test at review time is minutes. The cost of finding out it was missing is usually a support escalation and an uncomfortable conversation about what a customer's contractor could see.

When to Reach for an Off-the-Shelf Authorization Library

Not every team should build this from scratch, and it's worth being honest about when a library is the better call. If your permission logic starts needing attribute-based rules ("approve invoices under $500 but not over"), policy engines like Open Policy Agent or authorization libraries like Casbin handle that complexity better than a hand-rolled system will.

If you're already using a hosted auth provider, check what it offers before building parallel infrastructure. Providers like Auth0 ship role and permission management that covers a lot of the ground described here, and it's often faster to configure than to maintain. Database-level enforcement is also worth knowing about: PostgreSQL row-level security can enforce scoping at the query layer as a defense-in-depth measure even when your application code has its own checks.

For teams that want the background reading, Wikipedia's overview of role-based access control and the OWASP cheat sheet series on access control are solid starting points before you commit to an architecture.

Macro photo of a metal keychain with several keys
Photo by Luis Medina Diseño on Pexels

Getting the Foundation Right the First Time

The teams that handle this well don't try to predict every future role on day one. They build the separation between users, roles, permissions, and scope correctly from the start, then let the actual roles grow organically as customers ask for them. That separation is the part that's expensive to retrofit; everything else is just configuration.

If your product is still on an admin/user flag and support tickets about access are becoming a pattern, it's worth designing the migration path before the next enterprise deal forces it on an accelerated timeline. 137Foundry has worked through this exact migration on production systems, and our web development team can help scope what a phased rollout looks like for your specific data model.

Whatever you build, keep the permission check itself boring, centralized, and heavily tested. That's the part of the system where clever shortcuts turn into the incidents nobody wants to explain to a customer. If you want a second pair of eyes on your current approach, 137Foundry's services page has more on how engagements like this typically start, and you can read more about how we work on our about page.

Need help with Web Development?

137Foundry builds custom software, AI integrations, and automation systems for businesses that need real solutions.

Book a Free Consultation View Services