Multi-role healthcare permission management, a technical case study
Building a centralized RBAC system for a healthcare SaaS with six roles, DOM-level permission enforcement, and JWT-scoped API filtering.
Healthcare platforms serve people with fundamentally different jobs, and fundamentally different data access needs. A physician, an attorney, and a billing admin using the same system should each see a different slice of it, and none of them should be able to see more than their role allows.
The naive approach, scattering role checks across a codebase, works for about three months. Then a new feature ships, a check gets missed, and someone can see data they shouldn't. I built the permission system for Synectus Medico specifically to avoid that failure mode, and this case study covers how.
The business problem
Medico serves six distinct user types: Super Admin, Clinic Admin, Executive, Physician, Attorney, and Doctor. Each has different rules for what they can view, create, edit, or delete. Hard-coding role checks across dozens of components and API routes doesn't scale, and it's exactly the kind of thing that quietly breaks as a codebase grows and multiple developers touch it.
The goal was a centralized system: define permissions once, enforce them consistently everywhere, and make it easy to reason about who can do what.
The solution
A flat key-value permission map, paired with a React Context that exposes permission checks to any component in the tree, and UI components that read from that context to decide whether to render at all.
The key architectural decision: components that lack a required permission are removed from the DOM entirely, not disabled. A disabled button is still inspectable, its handler is still attached, and a motivated user can potentially trigger it anyway. A component that never rendered has nothing to find.
Route-level vs component-level protection
Two layers, working together. Route-level protection wraps entire pages: an attorney navigating directly to a billing-configuration URL gets redirected before the page even renders.
Component-level protection goes finer, inside a page that multiple roles can access. A Clinic Admin and an Executive might both land on the same dashboard, but the Clinic Admin sees a "Manage Staff" button the Executive doesn't, because that specific component checks its own permission key independently of the route-level check.
Why removing from the DOM matters more than it sounds like
This was a deliberate choice, not a default. Early on, permission checks were implemented as simple conditional styling, disabled buttons, grayed-out sections. It worked visually, but left a gap: the underlying markup and event handlers were still present in the rendered page.
Switching to conditional rendering (returning null instead of a disabled component) closed that gap. If a user's role doesn't include a given permission, that piece of UI simply doesn't exist in what gets sent to the browser.
Disabled button
Still rendered, still in the DOM, its markup and event handler still present for anyone who inspects the page or crafts a request around it.
Removed from the DOM
Returns null instead of a disabled component. If the role lacks the permission, that piece of UI never existed in what shipped to the browser.
Server-side enforcement: the part that actually protects data
None of the frontend work matters if the API doesn't independently enforce the same rules. Medico's API layer pre-filters data by role and identity before it ever reaches a response. An attorney's request for case data gets filtered by their own attorney ID, extracted server-side from their JWT token, not from anything the client sends.
This means a request built entirely outside the app, bypassing the UI, still can't retrieve data the requester's role shouldn't see.
Dynamic role and permission management
Roles aren't static in a growing platform. The permission map lives in the database and is editable through an admin interface, so adding a new role or adjusting an existing role's permissions doesn't require a code deployment.
How permission checks fit into the actual request lifecycle
A request arrives at the API with a JWT. Middleware decodes the token and attaches the user's role, tenant ID, and (for attorneys) their attorney ID to the request context. The route handler checks whether the required permission for that action is present for the user's role. If it's a data-fetching endpoint, the query is scoped by tenant ID and, where relevant, by the specific identity pulled from the token, not from any parameter the client controls.
On the frontend, the same JWT gets decoded on login and populates the permission context. There's exactly one source of truth for what each role's permission map looks like, referenced by both route-level and component-level checks, which avoids two different permission mechanisms slowly drifting apart from each other.
Outcomes
- Six roles, dozens of permission-gated UI surfaces, and route-level protection driven from one centralized permission map instead of scattered logic
- New roles and permission adjustments happen through configuration, not code changes
- Frontend permission system stays a UX concern rather than the actual security boundary, because the API enforces access independently
FAQ
Conclusion
Permission systems in multi-role healthcare platforms live or die on discipline: one centralized source of truth, DOM-level enforcement on the frontend, and independent enforcement on the API. Get all three right and adding a new role later is a configuration change, not a rewrite.
Building something similar?
Book a free 30-minute discovery call. No pitch, just a conversation about what you are building and how to approach it right.
Start the Conversation