Azure Role-Based Access Control: A Deep Dive
Most Azure security incidents don’t start with a sophisticated exploit. They start with someone having more access than they should. Overly broad role assignments and poor identity management are consistently among the leading causes of cloud breaches — and they’re entirely preventable. Let’s get the basics right.
In this article I’ll walk you through Azure Role-Based Access Control (RBAC) from the ground up: how it works, when built-in roles aren’t enough, and how to apply least privilege in practice with a real-world example.
What Is Azure RBAC and Why Does It Matter?
Azure RBAC is the authorization system that controls who can do what across your Azure resources. Instead of granting blanket access and hoping for the best, RBAC lets you define precise permissions at the right level — whether that’s an entire management group or a single storage account.
The core idea is least privilege: every identity should have exactly the access it needs to do its job, and nothing more. A service principal that reads VM performance metrics has no business being able to redeploy that VM. RBAC is how you enforce that boundary.
Core Concepts: The Four Pillars
To understand how RBAC works, you need to know its four building blocks. Every permission in Azure comes down to these.
Security Principal — Who is asking for access?
A security principal is any identity that can request access to Azure resources. There are four types:
- User — an individual account in Microsoft Entra ID
- Group — a collection of users; roles assigned to a group apply to all members
- Service principal — an identity used by applications or services to access Azure resources
- Managed identity — a service principal managed automatically by Azure, removing the need to handle credentials yourself
In practice, prefer groups over individual users and managed identities over service principals wherever possible. It keeps things clean and auditable.
Role Definition — What actions are allowed?
A role definition is a collection of permissions. It defines what a principal can and cannot do. Each role lists:
- Actions — management plane operations that are allowed (e.g. reading resource properties, creating VMs)
- NotActions — operations excluded from the allowed set. Important:
NotActionsis not a deny — it simply subtracts fromActions. An explicit deny assignment elsewhere always takes precedence. - DataActions / NotDataActions — same concept but for data plane operations (e.g. reading blobs, writing to a Cosmos DB container)
Azure ships with over 400 built-in roles covering most scenarios. When none of them fit precisely, you can define your own.
Scope — Where does it apply?
Scope defines which resources the role assignment covers. Azure scopes are hierarchical — permissions assigned at a higher level are inherited by everything below it:
Management Group
└── Subscription
└── Resource Group
└── Resource
Assign roles at the lowest scope that still satisfies the requirement. Assigning Owner at the subscription level when you only need access to one storage account is the kind of thing that creates incidents.
Role Assignment — Putting It All Together
A role assignment is the binding that connects a security principal, a role definition, and a scope. When Azure evaluates whether an identity can perform an action, it looks for a matching role assignment. If one exists at the relevant scope, access is granted. No assignment, no access.
Built-in Roles vs Custom Roles
In most cases, Azure’s built-in roles will cover what you need. But there are situations where they don’t fit precisely enough.
| Feature | Built-in roles | Custom roles |
|---|---|---|
| Origin | Created and maintained by Microsoft | Created and managed by your organization |
| Modification | Cannot be edited or deleted | Fully editable, updatable, and deletable |
| Availability | Globally available at any scope | Restricted to defined AssignableScopes |
| Setup | Ready out of the box | Requires defining permissions then assigning |
| Limits | Unlimited | Maximum 5,000 per Microsoft Entra tenant |
When do custom roles make sense?
- A built-in role grants more access than the principal actually needs
- You need to block a specific action that a built-in role permits
- You’re following security frameworks like NIST or CIS that require tightly scoped permissions for service principals and automation
- You want to start from a built-in role and adjust a few permissions
One prerequisite worth knowing: creating custom roles requires Microsoft Entra ID P1 or P2.
Real-World Example: Scoping a Cosmos DB Role to Least Privilege
Let me walk through a scenario I’ve worked through that shows exactly when a custom role earns its place.
We have an Azure Function that reads and updates a single value in a Cosmos DB container — a visitor counter. The straightforward choice would be to assign the Cosmos DB Built-in Data Contributor role. But when you look at what that role actually allows, it’s far broader than what the function needs:
- Wildcard on
containers/*— covers stored procedures, triggers, conflict feeds, and container-level management - Wildcard on
items/*— covers create, delete, upsert, and query execution
Our function only needs to read an item and replace its value. Everything else is unnecessary exposure. So we create a custom role with exactly those two data actions.
Step 1: Define the role
Create a JSON file describing the role — its name, the scope it applies to, and the specific permissions it grants:
cat > role-definition.json << 'EOF'
{
"RoleName": "VisitorCounterReadReplace",
"Type": "CustomRole",
"AssignableScopes": [
"/dbs/<cosmos-account-name>/colls/<container-id>"
],
"Permissions": [
{
"DataActions": [
"Microsoft.DocumentDB/databaseAccounts/readMetadata",
"Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/read",
"Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/replace"
]
}
]
}
EOF
Notice the AssignableScopes is pinned to the specific container — not the database, not the account. Narrowest possible scope.
Step 2: Create the role in Azure
Register the role definition against your Cosmos DB account:
az cosmosdb sql role definition create \
--account-name <cosmos-account-name> \
--resource-group <rg-name> \
--body @role-definition.json
This returns a role definition ID you’ll need in the next step.
Step 3: Assign the role to the function’s managed identity
az cosmosdb sql role assignment create \
--account-name <cosmos-account-name> \
--resource-group <rg-name> \
--scope "/dbs/<db-name>/colls/<container-id>" \
--principal-id <function-managed-identity-id> \
--role-definition-id <role-definition-id-from-previous-step>
Step 4: Remove the overly broad assignment
Now that the tighter role is in place, remove the Cosmos DB Built-in Data Contributor assignment. First, list existing assignments to find the one to delete:
az cosmosdb sql role assignment list \
--account-name <cosmos-account-name> \
--resource-group <rg-name> \
--output json
Then delete it by ID:
az cosmosdb sql role assignment delete \
--account-name <cosmos-account-name> \
--resource-group <rg-name> \
--role-assignment-id <assignment-id>
The function now has exactly two data actions available to it. Nothing more.
Best Practices
After working through RBAC configurations across different environments, these are the principles I keep coming back to:
Assign roles to groups, not individuals. When you assign a role directly to a user, you create a management problem that compounds over time. Use Entra ID groups — when someone leaves or changes teams, you update group membership, not role assignments.
Apply roles at the lowest effective scope. If access is only needed for a single resource group, assign the role there — not at the subscription level. The scope hierarchy makes it easy to be lazy here; don’t be.
Avoid the Owner role unless strictly necessary. Owner grants full control including the ability to assign roles to others. It should be rare and tightly audited. Contributor covers most administrative needs without the role-assignment capability.
Use managed identities for workloads, not service principals with secrets. Managed identities eliminate credential management entirely. For any workload running on Azure infrastructure, there is almost no reason to use a service principal with a client secret instead.
Audit role assignments regularly. Access has a tendency to accumulate. Use Azure Policy or Entra ID Access Reviews to periodically verify that assignments still reflect actual requirements. The assignment you created for a contractor six months ago is still active until someone explicitly removes it.
Follow the principle of least privilege from day one. It is significantly harder to reduce permissions in an existing environment than to start with narrow access and expand it when needed.
Wrapping Up
Azure RBAC is one of those foundational pieces that most teams treat as an afterthought until something goes wrong. Getting it right from the start — choosing the right scope, using groups, reaching for custom roles when built-ins are too broad — is what separates a secure environment from one that’s one misconfiguration away from a bad day.
The Cosmos DB example in this article is a pattern I’ve applied across different scenarios: start with what the workload actually needs, check whether a built-in role covers it precisely, and create a custom role when it doesn’t. It takes a bit more upfront work but the reduction in exposure is worth it every time.
If you’re building on Azure and want to go further, Microsoft’s RBAC documentation and the Entra ID built-in roles reference are the two resources I keep open most often.