We've all been there.
Your first encounter with authorization looks something like this:
if (user.role === "ADMIN") {
// allow access
}
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
}
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
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
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
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();
}
Better:
if (user.permissions.includes("USER_DELETE")) {
deleteUser();
}
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
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
2. Role Boundary
Question:
Are you allowed into this area of the system?
Examples:
Staff Portal
Customer Portal
Admin Dashboard
Partner Portal
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
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(...) {
...
}
Request flow:
Request
↓
JWT Validation
↓
Role Boundary Check
↓
Permission Check
↓
Controller
↓
Business Logic
If the permission is missing:
403 Forbidden
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();
}
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
Others prefer:
Hide the action entirely
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
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
Example JWT payload:
{
"sub": "123",
"permissions": [
"LOAN_APPROVE",
"REPORT_EXPORT",
"USER_VIEW"
]
}
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
With database-driven permissions:
Create Role
Assign Permissions
Done
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.
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)