DEV Community

Cover image for Stop Hardcoding Roles: A Practical Guide to Roles, Permissions, and Scalable Authorization
Dennis Ogweno
Dennis Ogweno

Posted on

Stop Hardcoding Roles: A Practical Guide to Roles, Permissions, and Scalable Authorization

We've all been there.

Your first encounter with authorization looks something like this:

if (user.role === "ADMIN") {
  // allow access
}
Enter fullscreen mode Exit fullscreen mode

It works.

It's simple.

It ships fast.

And then, three months later, your application has grown, requirements have shifted, and you're staring at a codebase where authorization logic is scattered everywhere—APIs, services, UI components—like a puzzle that nobody remembers how to solve.

The truth is: this approach doesn't scale.

Not because it's inherently flawed, but because it conflates two very different concepts that should never be mixed.


The Core Mistake: Confusing Identity with Capability

Here's the problem we're actually trying to solve.

As your application grows, you inevitably end up writing code like this:

if (
  user.role === "BRANCH_MANAGER" ||
  user.role === "SYSTEM_ADMIN"
) {
  // allow access
}
Enter fullscreen mode Exit fullscreen mode

Then a stakeholder asks:

Can we create a hybrid role?

Or:

We need Auditors who can export reports but not edit records.

And suddenly your role logic explodes into an unmaintainable mess.

The fix isn't adding more conditions.

The fix is understanding that roles and permissions answer fundamentally different questions.


Roles Define Identity

Roles are categories of users.

Examples:

SYSTEM_ADMIN
CLIENT
BRANCH_MANAGER
AUDITOR
Enter fullscreen mode Exit fullscreen mode

Roles answer:

Who is this user?

They establish high-level authorization boundaries.

Examples:

  • Staff Portal vs Customer Portal
  • Internal Admin Area vs Public Application
  • Employee Features vs Client Features

Think of roles as identity labels.


Permissions Define Capability

Permissions represent atomic actions.

Examples:

LOAN_APPROVE
USER_DELETE
REPORT_EXPORT
ACCOUNT_EDIT
Enter fullscreen mode Exit fullscreen mode

Permissions answer:

What can this user actually do?

Your application should not constantly ask:

What role are you?

Instead, it should ask:

Do you have permission to perform this action?

Because:

Users have Roles
Roles contain Permissions
Code checks Permissions
Enter fullscreen mode Exit fullscreen mode

That distinction changes everything.


Always Decouple Identity from Capability

This is one of the most important principles in authorization design.

Bad:

if (user.role === "ADMIN") {
  deleteUser();
}
Enter fullscreen mode Exit fullscreen mode

Better:

if (user.permissions.includes("USER_DELETE")) {
  deleteUser();
}
Enter fullscreen mode Exit fullscreen mode

Now your code doesn't care whether the user is:

  • ADMIN
  • SUPER_ADMIN
  • SUPPORT_MANAGER

As long as they possess the required capability.

That's flexibility.


The Authorization Pyramid

Instead of building one giant authorization mechanism, think in layers.

Each layer should answer exactly one question.

Authentication
      ↓
Role Boundary
      ↓
Permission Check
      ↓
Business Verification
Enter fullscreen mode Exit fullscreen mode

Let's break that down.


1. Authentication

Question:

Are you who you claim to be?

Examples:

  • JWT validation
  • Session validation
  • OAuth verification

If this fails:

401 Unauthorized
Enter fullscreen mode Exit fullscreen mode

2. Role Boundary

Question:

Are you allowed into this area of the system?

Examples:

Staff Portal
Customer Portal
Admin Dashboard
Partner Portal
Enter fullscreen mode Exit fullscreen mode

A customer should never reach internal administration routes.

An employee should never be redirected into customer-only experiences.

This is where role checks make sense.


3. Permission Check

Question:

Can you perform this specific action?

Examples:

Approve Loan
Export Report
Delete User
Create Invoice
Enter fullscreen mode Exit fullscreen mode

This is where permissions shine.


4. Business Verification

Question:

Does the current system state allow this action?

Examples:

  • Is the account verified?
  • Is the loan eligible?
  • Is the subscription active?
  • Is the invoice already paid?

Notice that this has nothing to do with authentication or authorization.

It's business logic.

Keep it separate.


My Preferred Backend Flow

I prefer enforcing authorization through middleware or interceptors before business logic executes.

For example:

@RequirePermission("LOAN_APPROVE")
public Loan approveLoan(...) {
    ...
}
Enter fullscreen mode Exit fullscreen mode

Request flow:

Request
  ↓
JWT Validation
  ↓
Role Boundary Check
  ↓
Permission Check
  ↓
Controller
  ↓
Business Logic
Enter fullscreen mode Exit fullscreen mode

If the permission is missing:

403 Forbidden
Enter fullscreen mode Exit fullscreen mode

before any business code executes.

This keeps controllers clean and authorization centralized.


The Illusion of Frontend Security

Here's a hard truth.

Frontend guards are about user experience, not security.

This:

if (user.permissions.includes("USER_DELETE")) {
  renderDeleteButton();
}
Enter fullscreen mode Exit fullscreen mode

does not secure anything.

It simply hides a button.

Anyone can still attempt to call the API.

Which means:

Every authorization rule enforced on the frontend must also be enforced on the backend.

Always.

The backend is the source of truth.


Hide or Disable?

This is often debated.

Some teams prefer:

Disabled button
Tooltip explaining why
Enter fullscreen mode Exit fullscreen mode

Others prefer:

Hide the action entirely
Enter fullscreen mode Exit fullscreen mode

Personally, I favor hiding actions users cannot perform.

If a user lacks permission to delete records, I generally don't show the delete action at all.

A cleaner interface creates less confusion and reduces cognitive load.

That said, accessibility and transparency requirements may lead some teams toward disabled controls.

Choose deliberately.


Move Authorization State Into the Database

Hardcoding role-permission mappings in code works for prototypes.

Eventually it becomes technical debt.

Instead, use a relational model:

users
  ↓
user_roles
  ↓
roles
  ↓
role_permissions
  ↓
permissions
Enter fullscreen mode Exit fullscreen mode

This gives you:

  • Dynamic administration
  • Auditability
  • Flexibility
  • Scalability
  • Reduced deployments

Need a new role?

Add it in the database.

Need a new permission?

Add it in the database.

Need a custom role for a specific customer?

No code changes required.


The Authorization Flow

A common production architecture looks like this:

User Logs In
      ↓
Backend Loads Roles
      ↓
Backend Resolves Permissions
      ↓
JWT Created
      ↓
Frontend Receives JWT
      ↓
UI Renders Appropriate Features
      ↓
Backend Revalidates Every Request
Enter fullscreen mode Exit fullscreen mode

Example JWT payload:

{
  "sub": "123",
  "permissions": [
    "LOAN_APPROVE",
    "REPORT_EXPORT",
    "USER_VIEW"
  ]
}
Enter fullscreen mode Exit fullscreen mode

The frontend uses these permissions to drive UX.

The backend uses them to enforce security.


When Requirements Inevitably Change

And they will.

A stakeholder will ask for:

An Auditor role that can export reports but cannot edit records.

Later:

We need a Compliance Auditor with one extra permission.

With hardcoded role logic:

Refactor
Test
Redeploy
Hope nothing breaks
Enter fullscreen mode Exit fullscreen mode

With database-driven permissions:

Create Role
Assign Permissions
Done
Enter fullscreen mode Exit fullscreen mode

No deployment.

No code change.

No risk.


The Principle That Wins

The core insight is simple:

Decouple who the user is from what the system allows them to do.

When you separate identity from capability:

  • Architecture stays predictable
  • Authorization becomes composable
  • Requirements become easier to accommodate
  • Security becomes easier to reason about

The pattern is straightforward:

Roles define boundaries.
Permissions define actions.
Code checks permissions.
Backend enforces everything.
Enter fullscreen mode Exit fullscreen mode

Everything else follows from that.

How do you handle authorization in your applications: hardcoded roles, permissions, a hybrid RBAC model, or something else entirely? What trade-offs have you encountered as your system scaled?

Top comments (0)