We use analytics to understand how our website is used. No personal data is collected.

August 25, 2025 · Piyush Ranjan Mishra

Designing Role-Based Access Control That Doesn't Fall Apart at Scale

SecurityArchitectureSaaSLoot Discount

I built role-based access control for granular user permissions across the Loot Discount platform — a fintech product moving real money, where the security and compliance bar was set by what auditors would ask rather than by what felt sufficient. RBAC sounds simple in the abstract — assign roles, roles have permissions, check permissions before allowing actions — and it stays simple for about the first five roles. What actually determines whether an RBAC system is still maintainable a year later is a handful of modeling decisions made early, usually under time pressure to just ship a permission check.

Permissions as capabilities, not role names

The mistake that causes the most pain later: checking user.role === "admin" scattered through the codebase instead of checking a specific capability. The first version works fine until someone needs a role that’s “mostly admin, but can’t delete accounts” — and now you’re either creating role variants combinatorially, or sprinkling role === "admin" || role === "admin_lite" checks through the code. The fix, which I wish I’d started with everywhere instead of retrofitting: roles are just named bundles of capabilities, and every permission check tests a capability (canDeleteAccount), never a role name directly.

const roleCapabilities: Record<Role, Capability[]> = {
  admin: ["viewBilling", "manageUsers", "deleteAccount", "manageIntegrations"],
  manager: ["viewBilling", "manageUsers"],
  member: ["viewBilling"],
};

function can(user: User, capability: Capability): boolean {
  return roleCapabilities[user.role]?.includes(capability) ?? false;
}

This one indirection — checking capabilities, deriving them from roles — is what let new roles get added later as pure configuration, without touching the permission-check call sites scattered across the app.

Resource-scoped permissions, not just global ones

A flat “can manage users” permission breaks down the moment a platform is multi-tenant and a user might be an admin in one organization and a plain member in another (a real scenario at Loot Discount, since the platform served multiple business accounts). Permissions needed to be scoped to a resource — “can manage users within this organization” — not just a global flag on the user. This meant the permission check always needed two inputs: the user and the resource/organization context, never just the user in isolation. Skipping this scoping early and retrofitting it later is expensive, because every unscoped permission check in the codebase becomes a potential cross-tenant data leak that needs auditing individually.

Deny-by-default, and treat missing capability as denied, not as an error to work around

Every capability check defaulted to false when a role or capability wasn’t explicitly found — a new role accidentally missing from the capability map fails closed (denies access) rather than failing open (accidentally grants it). This is the boring, obviously-correct choice, and it’s still worth stating explicitly, because under deadline pressure it’s tempting to write a permission check that defaults to allow “just for now” while a role’s permissions are being finalized — and “just for now” defaults have a way of reaching production.

Auditability: knowing who could do what, and when

Beyond enforcing permissions, a real operational need emerged: being able to answer “who had access to X, and when” for security reviews and incident investigation. This meant permission changes (role assignments, capability grants) needed their own audit log, separate from the general application audit trail, because access-control history has different retention and query needs than ordinary activity logs — you’re often looking backward from an incident to reconstruct exactly who could have done something, which requires the access-control history to be queryable independently and reliably.

Where I’d push back on over-engineering RBAC

Not every product needs a fully generalized, admin-configurable permission system with custom roles. If you have a fixed, small set of roles that changes rarely (admin/manager/member, not a customer-configurable permission matrix), a simpler static capability map like the one above is the right amount of complexity — building a fully dynamic, database-driven permission engine for a problem that doesn’t need that flexibility is its own maintenance burden, and one I’ve seen teams pay for without ever using the flexibility they built.

The actual lesson

RBAC systems don’t fail because role-checking logic is hard to write — the naive version is easy. They fail because early shortcuts (role-name checks instead of capability checks, unscoped global permissions, permissive defaults) compound as the number of roles and resources grows, and by the time the pain is obvious, every call site needs auditing to fix it. The capability-indirection and resource-scoping decisions are cheap to make on day one and expensive to retrofit — that asymmetry is the whole argument for getting them right early.