Access control is unusual among the things we build in that it has to be right everywhere, forever, including in the endpoint someone adds in a hurry eighteen months from now. Most code can be wrong in one place and merely produce a bug. Permissions wrong in one place is the whole feature failing, and it fails silently, and you typically find out from someone outside the company.
What follows is the set of decisions that determine whether a permissions system holds up under that requirement. It's drawn from building role-based access into a dashboard where different kinds of user — staff, vendors, support — see genuinely different slices of the same data.
Check permissions, not roles
The first fork, and the one that costs most to get wrong.
The tempting version is to check the role directly: if (user.role === 'admin'). It reads well and it works on day one. Then you add a "support" role that also needs to refund an order, and you find every place that asked whether someone was an admin. Then a senior support role. Then one customer wants an admin who can't see financials. Each addition is a search-and-edit across the codebase, and each one is a chance to miss a site.
Check the capability instead: if (can(user, 'order.refund')). Roles become named bundles of permissions — data, not branches in code. Adding a role is then a configuration change rather than a code change, and the question "who can refund an order?" has an answer you can look up instead of grep for.
The permission names are worth care. Name them after the action being authorised — order.refund, vendor.approve — not after the screen they happen to appear on. Screens get redesigned; the action is the durable thing.
Most real systems need scope, not just capability
"Can this user refund orders" is only half a question. The other half is which orders. A vendor can refund their orders. A regional manager sees their region. A support agent may see everything but change nothing.
This is the part a pure role-based model handles badly, and pretending otherwise is how you end up with thirty roles named things like vendor_manager_region_apac_readonly. Once ownership or region or team membership decides access, the check needs the resource in hand, not just the user: it's can(user, 'order.refund', order), and the implementation asks both whether the capability is held and whether this particular record is in scope.
Where the check goes is the whole game
Here is the failure I'd bet on finding in any system that has grown for a few years without this being deliberate.
Someone writes a page that lists orders. The query fetches orders; the template renders the ones belonging to the current vendor. It looks correct, it demos correctly, and it is correct — for that page.
Then someone adds a CSV export, reusing the query but not the template. Then a search endpoint. Then a sort parameter, and sorting happens before the filter. Then a mobile API. The filter lived in the view, so each new consumer of that query is a fresh opportunity to forget it, and forgetting it produces no error — just a response containing other people's data.
Scoping belongs where the data is fetched, not where it is displayed. The base query for a resource should already be narrowed to what the current actor may see, so that getting it wrong requires deliberately opting out rather than merely forgetting. Make the safe path the default path and the unsafe one explicit and ugly — an unscoped() that shows up in review is worth a great deal.
This is the same argument I made about vendor isolation in a multi-vendor marketplace, where the consequence is a vendor seeing a competitor's sales. The principle generalises: enforcement at the boundary where data leaves the database is the only one that covers code nobody has written yet.
The UI is not enforcement
Hiding a button is a courtesy to the user, not a control. Anyone can call the endpoint directly, and "the button wasn't visible" has never stopped anything.
So every permission gets checked server-side, on every request, with the UI reading the same permission set purely to decide what to render. What you want to avoid is two implementations — a list of rules in the frontend and a different list in the backend — because they will disagree, and the direction they disagree in is usually "the button is hidden but the endpoint works".
The arrangement that has worked for me: the server sends the user's effective permissions with the session, the client uses that to decide visibility, and the server re-checks on every call regardless. One source of truth, two consumers, no duplicated rule logic.
Fail closed
A permission check on an unknown action should deny. A resource with no ownership rule defined should deny. A user whose role failed to load should be denied, not treated as having no restrictions.
This sounds obvious stated plainly, and the way it goes wrong is never a decision — it's a default. A lookup returns undefined for an unrecognised permission,undefined is falsy, and the code happens to be written as "deny if explicitly forbidden". Now anything unrecognised is allowed, and the system's behaviour for a typo'd permission name is to grant it.
Make the default branch of that logic a denial, and write the test that proves an invented permission string is refused.
Log the denials, and the sensitive grants
Denials are your smoke alarm. A user hitting authorisation failures against endpoints they've never used is either a bug you need to know about or someone probing, and both are things you want to see rather than infer later.
Grants matter too, for the actions where the question "who did this and when" gets asked eventually: refunds, permission changes, exports of personal data, anything touching money. Record actor, action, target and time. The rule of thumb is that if an action would be awkward to explain in a meeting, it should be possible to answer who without reconstructing it from application logs.
Test the negative cases
Almost every permissions test suite I've seen tests that the admin can do the thing. Almost none test that the non-admin cannot, and only the second kind of test can fail in the way you're afraid of.
For any endpoint that matters, write the test where the wrong user calls it and asserts a 403. Write the one where a vendor requests another vendor's record by ID and asserts they can't have it. Those tests are the executable version of your security model, and they're what stops a refactor eighteen months from now from quietly removing a check that nothing else was verifying.
The short version
Check capabilities rather than roles, so adding a role is configuration. Scope at the query, so new consumers inherit safety instead of having to remember it. Treat the UI as presentation and the server as the authority. Default to denial. Log denials and sensitive grants. Test that the wrong person is refused.
None of it is difficult individually. What makes access control hard is that it has to keep being true in code written by people who weren't in the room — which is why the answer is structural, and why "we'll remember to check" is not a design.