Check Permissions, Not Roles
Most authorization messes trace back to one confusion: treating a role as the thing you check, when it's really just the thing you assign. Get that backwards and you get code full of "if role == admin" that cracks the day the business invents a second kind of admin. Here's the model that doesn't.
Listening · Check Permissions, Not Roles
You built it the obvious way. An admin can do admin things, so you wrote if user.role == "admin" at every gate that matters, and it worked. Then the business asked for a "billing admin" who can issue refunds but can't touch user accounts, and a "support admin" who can read everything but change almost nothing. Suddenly your tidy check is a lie, because "admin" now means three different things, and every one of those role == "admin" lines is either too generous or too strict, scattered across the whole codebase.
That's role confusion, and it's the most common authorization bug there is. You checked who someone is when you should have checked what they're allowed to do.
Roles are not permissions
Here's the distinction the whole thing hinges on. A permission is a single specific capability: refund_payment, delete_user, view_reports. A role is a named bundle of permissions: "billing admin" is just a label for "has refund_payment and view_invoices and not much else."
Both are useful, but they have different jobs, and the jobs are easy to swap by accident. Roles are for humans, they're how you assign access, "make her a billing admin" is a sentence a person can say and mean. Permissions are for code, they're what you actually check. The bug is using the human's convenient label as the machine's check.
The rule: check the permission at the call site
So the fix is a discipline you apply everywhere access is decided. At the point where your code guards an action, it asks about the permission, never the role:
// wrong: couples every call site to a role name
if (user.role === "admin") { ... }
// right: asks the actual question
if (user.can("refund_payment")) { ... }
The role still exists, it's just not what you check. It lives up in the assignment layer as a bundle: the "billing admin" role grants refund_payment, and the call site asks whether this user has refund_payment, not how they got it. Now the day you add a "senior support" role that also refunds, you grant it that permission and every existing check just works, because none of them ever cared about the role in the first place.
Why role-checking explodes
Checking roles doesn't just couple your code to labels, it multiplies them. Every new combination of capabilities needs a brand-new role, because the role is the unit of checking. You start with admin. Then super_admin. Then billing_admin, readonly_admin, regional_admin, and the genuinely cursed admin_but_not_that. Each one forces you back through the codebase to add another branch to the if ladder.
Permissions collapse that. A handful of granular permissions recombine into as many roles as the business dreams up, and the code never changes, because the code only ever asks about permissions. Roles become configuration, not code. That's the whole payoff.
Where RBAC runs out
Now the honest limit, because RBAC solves one problem and quietly doesn't solve another. RBAC answers "can this user delete invoices" as a general capability. It does not answer "can this user delete this invoice", the specific one, that belongs to a different customer.
That second question is resource-level authorization, and it's a different check entirely, tied to ownership or relationship rather than role. A user can hold the delete_invoice permission in general and still have no business touching invoice 1043 because it isn't theirs. If that distinction sounds familiar, it's the same gap that makes changing a URL id dangerous: the capability check passed, the ownership check was never written. RBAC handles the coarse "what kind of thing can you do." You still need a per-resource check for "on this specific object," and sometimes attribute-based rules on top ("only during business hours," "only in their own region"). Real systems layer all three. The mistake is thinking a role check covered a question it never even asked.
How I wire it
Concretely, in a Laravel app I lean on Spatie's permission package, which treats permissions and roles as first-class rows: define granular permissions, group them into roles, assign roles to users, and then check permissions through can() at the call site and in policies. Roles for assigning, permissions for checking, exactly as above. Resource-level "is this yours" lives in policies and gates, not in the role system at all, because it's a different question. The library isn't the point, the separation is, but having roles and permissions as real data rather than hardcoded strings is what makes the separation easy to keep.
Three layers, then, and keeping them distinct is most of authorization: roles are how humans hand out access, permissions are what your code checks, and per-resource ownership is a separate check that no role will ever answer for you. Muddle any two of those and you get the tangle. Keep them apart and it stays boring, which is exactly what authorization should be.