How to design roles & permissions properly

Start with roles. Add attribute-based rules only when roles break. Don't invent your own policy language.

· LoginWith team

Permissions design is one of those things that seems simple until your 15th customer asks for “editors should be able to edit posts they own but only read others,” and now your role column has 12 values and your query filters have exception branches.

Start with RBAC

RBAC (Role-Based Access Control) has four or five named roles, each with a fixed permission set:

  • admin — everything
  • editor — create and modify content
  • member — create and modify own content, read everything
  • viewer — read-only

For 80% of apps, this is the right level of granularity. It’s simple to implement (a role column on the membership), simple to explain to users, and simple to test (one integration test per role).

Don’t add fine-grained permissions until you have to. Every role you add doubles the combinatorial space of your tests.

Know when RBAC is failing

RBAC breaks when the rule depends on a property of the object, not just the role. Classic examples:

  • “Users can edit posts they created, but only view others’”
  • “Managers can approve expense reports from people who report to them”
  • “Only the workspace owner can delete the workspace”

Each of these is a rule about the relationship between the subject (the user) and the object (the specific post, expense report, workspace). You can’t express it as “role X has permission Y” — you need the object’s attributes.

That’s when you need ABAC (Attribute-Based Access Control) or a relationship engine (OpenFGA, Ory Keto).

The right ABAC level

Don’t leap from “four roles” to “full policy language.” There’s a middle ground that covers most needs: scoped permissions.

A scoped permission is “role X has permission Y on objects where Z.” Implementation-wise, it’s:

// Check: can this user edit this post?
function canEdit(user, post) {
  if (user.role === 'admin') return true
  if (user.role === 'editor') return true
  if (user.role === 'member' && post.authorId === user.id) return true
  return false
}

Extract this into a permissions.js module, write tests against it, and you’ve handled 90% of the cases that drove you out of pure RBAC. Only graduate to a full policy engine (Oso, OpenFGA) when the rule complexity exceeds what’s readable in JavaScript.

What not to do

  • Don’t invent your own policy language. People build DSLs, commit them, and two years later nobody on the team understands how permissions work. Use a library if you need a DSL.
  • Don’t put permission logic in SQL. It’s invisible in code review and impossible to test in isolation.
  • Don’t scatter checks across the codebase. Centralize in a permissions module.
  • Don’t make permissions user-configurable before you have 100+ customers. You’ll regret every edge case.

The rollout path

Ship RBAC first. Extend to scoped permissions in code when the rules get context-dependent. Adopt a policy engine only when the rules are too complex to read. That’s the path that avoids the permissions-module rewrite that kills a quarter.

Want auth that just works?

Get started with LoginWith