Most web applications start with a simple auth check: is this user logged in? If yes, let them through. That approach works until the first time you need to distinguish between a regular user, an admin, and a super admin. Then it works until you need to distinguish between a moderator who can edit content but not delete users, and an editor who can publish but not approve other editors' work.
The complexity of access control grows with the complexity of the application. Systems that weren't designed for it struggle to bolt it on later, and the debt compounds. The right time to design your authorization model is before you need it, not after you've accumulated hundreds of ad-hoc permission checks scattered through your codebase.
This is a practical guide to getting role-based access control right from the beginning.

Photo by panumas nikhomkhai on Pexels
What Role-Based Access Control Is (and What It Isn't)
Role-based access control, commonly abbreviated RBAC, is an authorization approach in which permissions are assigned to roles, and roles are assigned to users. A user's access to resources is determined by which roles they hold.
This is different from simply storing a boolean is_admin flag on the user object. RBAC formalizes the relationship between users, roles, and permissions into a model that can be reasoned about, tested, and extended. According to Wikipedia's overview of RBAC, the model was formalized in the early 1990s and has since become the dominant access control pattern in enterprise software.
The key components of an RBAC model are:
- Subjects: the users or processes requesting access
- Objects: the resources being accessed (a document, a user record, an API endpoint)
- Actions: what can be done to an object (read, write, delete, publish)
- Roles: named collections of permissions (viewer, editor, admin)
- Permissions: the association between a role, an object type, and an action
A user is assigned one or more roles. Each role carries a set of permissions. When a user attempts to perform an action on an object, the system checks whether any of their roles grants that permission.
Designing Your Permission Model Before Writing Code
The most important step in implementing RBAC is also the one most often skipped: defining the permission model in concrete terms before writing code.
The model should answer three questions for every role in your system:
- What resources does this role have access to?
- What actions can this role perform on each resource?
- Are there conditions that affect access (ownership, state, time, geography)?
The third question is where most systems underestimate complexity. Ownership-based conditions are the most common: a user can edit their own posts but not someone else's. State-based conditions are also common: a published article might be viewable by viewers but editable only by admins, while a draft article is editable only by its author.
Documenting this model as a matrix, with roles on one axis, resource-action pairs on the other, and cells containing either a simple yes/no or a condition expression, pays for itself immediately. It forces clarity before code, gives you a test specification, and makes the model reviewable by non-engineers.
Implementing Route-Level Authorization
The first layer of enforcement is at the route level. Before processing a request, confirm that the user's roles include permission to perform the requested action.
In an Express.js application, this typically looks like middleware:
function requireRole(...allowedRoles) {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: 'Unauthenticated' });
}
const userRoles = req.user.roles || [];
const hasRole = allowedRoles.some(role => userRoles.includes(role));
if (!hasRole) {
return res.status(403).json({ error: 'Forbidden' });
}
next();
};
}
// Usage
router.delete('/articles/:id', requireRole('admin', 'moderator'), deleteArticle);
router.post('/articles', requireRole('editor', 'admin'), createArticle);
This approach is clean and testable. Every route explicitly declares which roles can access it, and the middleware enforces that declaration before the handler runs.
A few implementation notes worth keeping in mind:
Return 403 (Forbidden) rather than 404 (Not Found) when a user is authenticated but lacks permission. Returning 404 hides the existence of the resource from unauthorized users, which is sometimes desirable, but it makes debugging authorization problems harder. Use 403 by default and only use 404 to obscure sensitive resource existence where there's a clear security reason.
Separate authentication (who are you?) from authorization (what can you do?). Authentication middleware should run first and populate req.user. Authorization middleware runs second and checks permissions. Mixing them creates a mess that's hard to maintain.

Photo by Matheus Bertelli on Pexels
Enforcing Authorization at the Data Layer
Route-level checks prevent unauthorized users from reaching endpoints. They don't prevent authorized users from accessing data they shouldn't see through legitimate endpoints.
Consider a user who has the editor role and can access the articles endpoint. Route middleware allows them in. But should they be able to see articles belonging to other editors? Or only their own? A route-level role check doesn't enforce that distinction. You need data-layer authorization.
The pattern is to scope every data query to the requesting user's permissions:
async function getArticles(req, res) {
const { user } = req;
let query = db('articles').where({ status: 'published' });
// Editors see their own drafts; admins see all drafts
if (user.roles.includes('admin')) {
query = db('articles'); // no filter
} else if (user.roles.includes('editor')) {
query = query.orWhere({ author_id: user.id, status: 'draft' });
}
const articles = await query;
return res.json(articles);
}
This keeps the permission logic in the query rather than filtering results after the fact. Post-fetch filtering is more error-prone and doesn't scale: you fetch data you don't need and may expose sensitive fields before you strip them.
OWASP's top 10 vulnerabilities list has consistently ranked broken access control at number one. The most common failure mode is exactly this pattern: routes are protected, but the underlying data queries don't enforce authorization boundaries. A user who knows or guesses a resource ID can often retrieve it directly through a properly-authenticated endpoint that doesn't scope queries to their actual permissions.
Patterns That Scale Well
A few authorization patterns hold up well as applications grow.
Hierarchical roles allow one role to inherit the permissions of another. An admin who implicitly has all editor and viewer permissions doesn't need to be assigned three separate roles. Hierarchy reduces maintenance overhead and makes the model more legible. Be careful with deep inheritance hierarchies; they become hard to reason about.
Resource ownership as a permission condition is one of the most common patterns and worth modeling explicitly. Rather than special-casing author_id === req.user.id throughout the codebase, define an owner permission type and enforce it consistently.
Scoped tokens reduce the blast radius of compromised credentials. Rather than issuing tokens with full role authority, issue tokens scoped to specific capabilities for specific sessions. A token issued for a password reset flow shouldn't also grant the ability to change email or delete accounts.
"RBAC starts breaking down the moment you let roles accumulate without a deprecation process. The question isn't which permissions to grant today, it's how you're going to audit and remove them six months from now." - Dennis Traina, founder of 137Foundry
Common Mistakes to Avoid
Role explosion is the most common RBAC failure mode in growing systems. Teams create a new role every time a slightly different permission set is needed, and within a year there are 40 roles that overlap in confusing ways. Define roles by job function and use conditions to handle variation within a role. If you're creating more than 8 to 10 distinct roles for a typical application, reconsider whether you're modeling the problem correctly.
Hardcoded role checks scattered through the codebase make the system unmaintainable. if (user.role === 'admin') in 200 places means changing the admin role's permissions requires touching 200 files. Centralize permission checks in a single authorization layer that's called from everywhere else.
No authorization audit log means you can't answer the question "who accessed what, when?" during a security incident. Log authorization decisions, not just authentication events. Include the user, the resource, the action, the decision (allow/deny), and a timestamp.
Trusting client-supplied role information is a critical error. Roles must always be resolved server-side from the user's authenticated identity. Never accept role claims from request headers, cookies, or body parameters without cryptographic verification.

Photo by Christina Morillo on Pexels
When to Reach for an Authorization Library
Simple RBAC is implementable without a library. Complex RBAC, particularly with fine-grained attribute-based conditions, policy inheritance, and multi-resource authorization, benefits from a purpose-built tool.
CASL is a well-maintained JavaScript/TypeScript library that implements attribute-based authorization with a clean API. It handles the condition-checking complexity that becomes tedious to implement manually and integrates well with frameworks like Express and Nest.
Casbin is a more powerful option that supports multiple authorization models (RBAC, ABAC, ACL) and can use different storage backends. It's a better fit for applications that need policy enforcement across multiple services.
Both libraries solve the problem of centralizing authorization logic without locking you into a specific infrastructure pattern. For most applications, CASL is the right starting point.
The JWT documentation at jwt.io is worth reviewing if you're encoding role information in tokens, as token structure and verification have direct implications for authorization security.
Connecting to 137Foundry
Designing authorization systems is one of the places where architectural decisions made early in a project have long-lasting consequences. If you're building an application that needs more than a basic auth check, or refactoring one that's outgrown its current permission model, 137Foundry's web development services includes experience building access control systems that hold up as products and teams grow.
For broader context on the services we offer, the 137Foundry services hub covers our approach to web development, data integration, and technical SEO.
The investment in getting authorization right early is smaller than the cost of retrofitting it later. Design the model, enforce it at both the route and data layer, centralize the logic, and build in the audit trail before you need it.

Photo by Jessica Lewis š¦ thepaintedsquare on Pexels