Three roles, one codebase: what actually breaks in role-based access control
Building a visa-processing platform for customers, staff, and admins in a single app surfaced the access-control bugs that don't show up until real users with real permissions collide.
It's common to reach for role-based access control (RBAC) as soon as an app has more than one type of user, and then treat it as solved once you've added a role column and a few if (user.role === 'ADMIN') checks. On a recent internal platform — customers submitting visa applications, staff processing the cases assigned to them, admins running the whole operation — that first pass is where the real bugs start, not where they end.
Hiding a button is not access control
The most common half-measure is checking roles in the UI only: don't render the admin dashboard link for a non-admin user, and call it done. That stops a user from clicking their way somewhere they shouldn't — it does nothing about someone hitting the underlying API route directly with a tool like curl or Postman. Every route that returns or mutates data needs its own authorization check, independent of whatever the UI happens to show. On this project that meant every API endpoint re-validated both the session and the caller's role, not just the page component that happened to render first.
"My role" is not the only scope that matters
Role alone answers "what kind of user is this," but not "which specific records is this user allowed to touch." A staff member has the STAFF role, but should only see the applications specifically assigned to them — not every case in the system. That's a second, narrower filter layered on top of the role check: every query for a staff member's case list is scoped by assignedToId matching their own user ID, at the database query level, not filtered client-side after fetching everything.
This is also where IDOR bugs (insecure direct object reference) creep in — a staff member guessing or incrementing an application ID in a URL and getting back another staff member's case because the endpoint checked "is this user a staff member" but not "is this specific case assigned to this specific staff member." The fix is boring and non-negotiable: every record-level endpoint checks ownership or assignment, not just role membership.
Admins need limits too
The role with the most power is usually the one that gets the least scrutiny, which is backwards — it's the role where a mistake has the largest blast radius. One specific guard worth calling out: admins can manage other users' roles, but the system blocks an admin from editing their own permissions. It's a small rule, but it closes off both an accidental self-lockout and a class of privilege-escalation bug where an admin account (or a compromised admin session) quietly grants itself more access than it already has.
Redirect by role, but don't rely on the redirect
After login, each role lands on its own area — customers go to a dashboard, staff to their case queue, admins to the overview console. That redirect is a convenience, not a security boundary. It's tempting to treat "the router sent them to the right place" as proof access is scoped correctly, but the redirect only decides where a legitimate request goes first — it says nothing about what happens if a customer manually navigates to /admin. That page, and every API route behind it, needs its own independent check, exactly like the button-hiding problem above. The pattern repeats at every layer: role and ownership checks belong on the data access itself, and every other convenience — hidden buttons, redirects, disabled menu items — is a UX nicety layered on top, never a substitute.