A permission model that still makes sense the year after you design it.
Most role-based access control systems start the same way: Admin, User, maybe Guest. Three roles, a handful of permissions, and everything is fine.
Then the company grows. Someone asks for a "Viewer" role. Then a "Billing Admin." Then a "Read-only Admin" — which is different from Viewer because reasons nobody can quite articulate. Two years in, the role list has forty-seven entries, nobody knows what any of them actually do, and adding a new feature means auditing every single row to figure out who should see it.
I've lived through that exact arc more than once. Here's what I've learned about designing authorization that doesn't collapse under its own weight.
Roles are coarse. Permissions are fine.
The single most useful distinction I've internalized is that roles and permissions are different things, and treating them as the same leads directly to role explosion.
Roles should map to job functions: Billing Manager, Support Agent, Engineer. A handful of roles that describe what a person is.
Permissions should describe actions: invoice.read, invoice.issue_refund, user.deactivate. Each one answers a specific yes/no question about a specific operation.
A role is then just a bundle of permissions. When the business asks for a new role, you don't write new permission-checking code — you compose an existing set in a different way. Adding a new feature means adding new permissions, not reshuffling every role in your database.
This split feels like extra ceremony on day one. It pays for itself about ten times over by year two.
Deny by default, always
The second rule is simple but unforgiving: if the system doesn't know whether someone can do something, the answer is no.
I've seen too many bugs from well-meaning defaults that fall open. A new permission gets added but nobody remembers to set it on existing roles; suddenly the permission is checked as missing, and "missing" gets interpreted as "not blocked." A release later, customer data leaks, and the postmortem is painful.
Deny-by-default closes that class of bug. If a check fails to find a grant, it returns false. Period. The cost is that you have to be deliberate about granting access. That's the right trade.
Log every authorization decision you'd want to explain later
The last piece is auditing. Every authorization decision on a sensitive resource should be logged with at least the user, role, resource, action, and outcome. Not in the "sometimes, if debug is on" sense — every time.
The reason is simple: the first time you need this log, you'll really need it. A security incident, a compliance review, a customer asking "who accessed my data between March 1 and March 8" — any of those questions become trivial if the log exists, and nearly impossible to answer honestly if it doesn't.
Storage is cheap. Reputation is not.
The test I apply
When I'm designing a new authorization model, I ask one question: "If the company doubles in the next year, will this still fit?" If the answer is "we'll just add more roles," I know I've built the wrong thing.
Roles should scale by composition, not by multiplication. Get that part right early, and most of the other problems get easier.