How to Implement Role-Based Access Control That Survives Contact With Customers

    6 min read3 approaches compared

    Almost every RBAC system starts as a `role` column with three values and ends as a permissions table with a join. The interesting question isn't which design is correct — it's how long you can defer the second one, and how much it costs when you can't defer it any longer.

    The forcing function is always the same: a customer asks for something your enum can't express. "Can Dana approve invoices but not create them?" There is no value of `role` that answers that, and the change is invasive because role checks are scattered through the codebase as string comparisons. This guide is about making that transition cheap, which mostly means one decision made early.

    Check permissions, store roles

    The single decision that determines how much the eventual migration hurts: application code should never ask 'is this user an admin?' It should ask 'can this user do X?' Roles then become a way of assigning permissions rather than a thing the code knows about.

    • Write `can(user, 'invoice.approve')`, never `user.role === 'admin'`. The first survives a permissions rewrite untouched.
    • One central place resolves a user to their permission set. Everything else calls it.
    • Start with roles mapping to hardcoded permission lists. That's a dozen lines and lets you move to a database table later without touching a single call site.
    • If you're B2B multi-tenant, roles are per-tenant from day one. A user who is an admin of one workspace and a viewer of another is not an edge case, it's the second customer.
    • Deny by default. A permission that doesn't exist yet should return false, not throw and not pass.

    The approaches

    Enum role with a permission map

    // best for: Everyone at the start, and many teams permanently.

    • Django
    • Ruby on Rails
    • Laravel
    • Express.js
    • Next.js
    • NestJS

    A `role` column plus a constant that maps each role to a set of permission strings. The database stays simple, permissions are version-controlled and reviewable in a pull request, and there's no join on the hot path because the map is in memory.

    This handles far more than people expect. If your roles genuinely are a hierarchy — viewer, editor, admin, owner — and customers aren't asking to customize them, there is no reason to build anything more elaborate. The upgrade path is clean precisely because call sites ask about permissions rather than roles.

    What kills it is customer-defined roles. The moment a customer wants to name their own role and choose its permissions, the map has to live in the database, because it's now data rather than code.

    typescript
    const ROLE_PERMISSIONS = {
      viewer: ["invoice.read"],
      editor: ["invoice.read", "invoice.create", "invoice.update"],
      admin:  ["invoice.read", "invoice.create", "invoice.update", "invoice.approve", "member.invite"],
    } as const;
    
    // Call sites ask about capability, never identity.
    // Moving this lookup to a database table later changes only this function.
    export function can(user: User, permission: Permission): boolean {
      return ROLE_PERMISSIONS[user.role]?.includes(permission) ?? false;
    }

    The gotcha

    Storing the role on the user rather than on the membership. The moment one user belongs to two organizations, a single `users.role` column is wrong and the fix touches every query. In any B2B product, the role belongs on the tenant-membership row from the very first migration — it costs nothing then and is painful later.

    Database-backed roles and permissions

    // best for: Products where customers create and configure their own roles.

    • PostgreSQL
    • MySQL
    • Supabase
    • MongoDB

    Four tables: `roles`, `permissions`, `role_permissions`, and `memberships` joining users to tenants with a role. Roles become rows, so customers can define "Billing Manager" with exactly the permissions they want.

    The cost is a join on every authorization check, which means caching. Resolve the permission set once per request and hang it on the request context — not per check, or a page rendering thirty gated components does thirty round trips. The cache invalidation question (what happens when an admin edits a role while a user is mid-session) is the genuinely hard part, and the usual answer is a short TTL plus accepting that permission changes take up to a minute to propagate.

    Keep system roles as rows too, flagged `is_system` and not editable. Mixing hardcoded roles and database roles in the same resolution path produces the worst bugs in this design.

    The gotcha

    Letting a user edit a role they hold in a way that removes their own last admin permission. Every customer-configurable RBAC system eventually locks someone out of their own workspace. Guard it explicitly: refuse any change that would leave a tenant with zero members holding the owner permission.

    Claims in the token

    // best for: Distributed systems where services can't share a permissions database.

    • Auth0
    • Clerk
    • Firebase
    • Supabase
    • WorkOS

    Bake the role or permission set into the JWT as a custom claim. Services then authorize from the token alone, with no database dependency — genuinely valuable when several services need to make authorization decisions and you don't want them all reaching into one table.

    The hard constraint is revocation. A token is a snapshot. Demote someone and their existing token still says admin until it expires. With a one-hour expiry, that's an hour of unintended access, which is unacceptable for anything sensitive. The mitigations — short expiries with refresh, or a revocation list checked per request — each give back some of the independence you bought.

    There's also a size limit. Firebase caps custom claims at 1000 bytes, which sounds generous until someone belongs to forty projects.

    The gotcha

    Custom claims only refresh when the token does, so a permission change is invisible to the client until then. Users see stale UI and hit confusing server-side rejections. If you use claims, you need an explicit token-refresh trigger on permission change, and the client has to handle a mid-session refresh.

    Common pitfalls

    Role checks scattered as string comparisons

    `if (user.role === 'admin')` in forty files means forty edits when the model changes, and it's guaranteed that one gets missed. Route every decision through one `can()` function. This is the cheapest thing on this list and the one that saves the most later.

    No permission for the thing that has no UI yet

    New endpoints ship without a permission check because there's no button for them, and the check is added 'when we build the UI.' The endpoint is public in the meantime. Default-deny at the router — an endpoint with no declared permission should be unreachable, not unrestricted.

    Conflating authentication with authorization in middleware

    One middleware that checks 'is logged in' and is treated as sufficient. Every authenticated user then reaches every endpoint. These are two separate questions and want two separate layers.

    Superadmin as a role rather than a separate mechanism

    Internal staff access modeled as a role in the same table as customer roles means one bad UPDATE grants a customer god mode. Keep internal access on a separate flag, ideally behind separate authentication and an audit log.

    Questions people actually ask

    Should I use RBAC or ABAC?

    RBAC unless you have a concrete requirement it can't express. Attribute-based access control — decisions based on properties of the user, resource, and context — is strictly more powerful and considerably harder to reason about or debug. Most teams that adopt ABAC early end up with an RBAC system expressed awkwardly in an ABAC engine.

    How granular should permissions be?

    One permission per user-visible action, named `resource.action`. Finer than that and nobody can configure it; coarser and customers will ask for something you can't express. Around 20 to 50 permissions covers most B2B products.

    Where should permission checks live?

    At the boundary of every entry point — HTTP handlers, background jobs, GraphQL resolvers — not deep in business logic. Checking in a service method that's called from three places means reasoning about three different callers' contexts.

    Need someone who's done this before?

    Browse vetted developers who work with these tools day to day.

    Related guides