Skip to main content

Azure RBAC Best Practices

RBAC is the connective tissue of Azure access control — every resource action is checked against it, and every misconfiguration either lets in too much or blocks something legitimate. The design decisions I get asked about most often are: how granular should roles be, where in the hierarchy should assignments live, and how do you keep it from becoming unmanageable as the estate grows.

How assignments work

Every assignment has three parts:

  1. Security Principal: Who needs access (user, group, service principal, managed identity)
  2. Role Definition: What they can do (Owner, Contributor, Reader, or custom)
  3. Scope: Where they can do it (management group, subscription, resource group, resource)

Formula: Security Principal + Role + Scope = Access

Key Concepts

Built-in Roles (Most Common)

RolePermissionsUse Case
OwnerFull access + manage accessSubscription/resource group admins
ContributorFull access, cannot manage accessDevelopers, operators
ReaderView-only accessAuditors, monitoring
User Access AdministratorManage access onlyDelegated admin rights
Specific rolesService-specificVirtual Machine Contributor, Storage Blob Data Reader

Scope Hierarchy

Management Group (highest)
└── Subscription
└── Resource Group
└── Resource (lowest)

Permissions assigned at a higher scope are inherited by child scopes.

Best Practices

1. Grant Least Privilege Access

I never assign Owner when Contributor is enough — and I never grant access at subscription scope when resource group scope works. Wildcard permissions in custom roles get flagged immediately.

I start with minimal permissions and add as needed. I use the most specific scope possible, and I reach for specialized roles like "Virtual Machine Contributor" before falling back to "Contributor".

# Bad: Too broad
az role assignment create \
--role "Contributor" \
--assignee user@contoso.com \
--scope /subscriptions/{subscription-id}

# Good: Specific role and scope
az role assignment create \
--role "Virtual Machine Contributor" \
--assignee user@contoso.com \
--scope /subscriptions/{subscription-id}/resourceGroups/rg-dev-vms

2. Use Groups Instead of Individual Users

I don't assign roles directly to individual users. I create Entra ID groups and assign roles to groups — it makes onboarding, offboarding, and auditing dramatically simpler.

Benefits:

  • Easier to manage at scale
  • Clear organization structure
  • Simplified onboarding/offboarding
  • Better auditability
# Create group in Entra ID
az ad group create \
--display-name "Database Administrators" \
--mail-nickname db-admins

# Assign role to group
az role assignment create \
--role "SQL DB Contributor" \
--assignee-object-id <group-object-id> \
--assignee-principal-type Group \
--scope /subscriptions/{sub-id}/resourceGroups/rg-databases

3. Use Managed Identities for Azure Resources

I don't create service principals with long-lived secrets when I can avoid it. I use system-assigned or user-assigned managed identities instead.

# Enable system-assigned managed identity on VM
az vm identity assign \
--resource-group rg-app \
--name vm-app-01

# Grant access to Key Vault
az role assignment create \
--role "Key Vault Secrets User" \
--assignee <vm-principal-id> \
--scope /subscriptions/{sub-id}/resourceGroups/rg-security/providers/Microsoft.KeyVault/vaults/kv-prod

4. Regularly Audit Role Assignments

# List all role assignments in subscription
az role assignment list --all -o table

# Find assignments for a specific user
az role assignment list \
--assignee user@contoso.com \
--include-inherited \
--include-groups -o table

# List role assignments at resource group
az role assignment list \
--resource-group rg-prod \
-o table

5. Use Custom Roles When Needed

Create custom roles when built-in roles are too permissive:

# Create custom role definition
az role definition create --role-definition @custom-role.json

custom-role.json:

{
"Name": "Virtual Machine Operator",
"Description": "Can start, stop, and restart VMs only",
"Actions": [
"Microsoft.Compute/virtualMachines/start/action",
"Microsoft.Compute/virtualMachines/restart/action",
"Microsoft.Compute/virtualMachines/powerOff/action",
"Microsoft.Compute/virtualMachines/read"
],
"NotActions": [],
"AssignableScopes": [
"/subscriptions/{subscription-id}"
]
}

6. Implement Separation of Duties

I've learned the hard way not to combine these roles for the same user:

  • Owner + User Access Administrator (too much power)
  • Developer + Auditor (conflict of interest)

Use different accounts for different responsibilities.

Track your assignments

I maintain a record of:

  • Which roles are used and why
  • Group naming conventions
  • Access request and approval process
  • Regular review schedule

Terraform Example

# Create resource group
resource "azurerm_resource_group" "app" {
name = "rg-app-prod"
location = "eastus"
}

# Get Entra ID group
data "azuread_group" "developers" {
display_name = "Developers"
security_enabled = true
}

# Assign Contributor role to developers group at RG scope
resource "azurerm_role_assignment" "developers_contributor" {
scope = azurerm_resource_group.app.id
role_definition_name = "Contributor"
principal_id = data.azuread_group.developers.object_id
}

# Get Entra ID group for readers
data "azuread_group" "auditors" {
display_name = "Auditors"
security_enabled = true
}

# Assign Reader role to auditors at subscription scope
resource "azurerm_role_assignment" "auditors_reader" {
scope = data.azurerm_subscription.current.id
role_definition_name = "Reader"
principal_id = data.azuread_group.auditors.object_id
}

# Create custom role
resource "azurerm_role_definition" "vm_operator" {
name = "Virtual Machine Operator"
scope = data.azurerm_subscription.current.id
description = "Can start, stop, and restart virtual machines"

permissions {
actions = [
"Microsoft.Compute/virtualMachines/start/action",
"Microsoft.Compute/virtualMachines/restart/action",
"Microsoft.Compute/virtualMachines/powerOff/action",
"Microsoft.Compute/virtualMachines/read",
"Microsoft.Compute/virtualMachines/instanceView/read"
]
not_actions = []
}

assignable_scopes = [
data.azurerm_subscription.current.id
]
}

# Use managed identity with RBAC
resource "azurerm_user_assigned_identity" "app" {
name = "id-app-prod"
resource_group_name = azurerm_resource_group.app.name
location = azurerm_resource_group.app.location
}

resource "azurerm_role_assignment" "app_storage" {
scope = azurerm_storage_account.app.id
role_definition_name = "Storage Blob Data Contributor"
principal_id = azurerm_user_assigned_identity.app.principal_id
}

CI/CD Integration

GitHub Actions with RBAC

name: Deploy Infrastructure

on:
push:
branches: [main]

jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2

- name: Azure Login
uses: azure/login@v1
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}

# Service Principal should have minimum required role
# Example: Contributor at resource group scope only

- name: Deploy resources
run: |
az deployment group create \
--resource-group rg-app-dev \
--template-file main.bicep

Service Principal Setup:

# Create SP with Contributor role scoped to specific resource group
az ad sp create-for-rbac \
--name "github-actions-sp" \
--role "Contributor" \
--scopes /subscriptions/{sub-id}/resourceGroups/rg-app-dev \
--sdk-auth

Azure DevOps with Service Connections

trigger:
- main

pool:
vmImage: 'ubuntu-latest'

variables:
serviceConnection: 'Azure-Production'

steps:
- task: AzureResourceManagerTemplateDeployment@3
inputs:
deploymentScope: 'Resource Group'
azureResourceManagerConnection: $(serviceConnection)
subscriptionId: $(subscriptionId)
action: 'Create Or Update Resource Group'
resourceGroupName: 'rg-app-prod'
location: 'East US'
templateLocation: 'Linked artifact'
csmFile: 'templates/main.bicep'

Things to Avoid

I don't assign Owner to service principals or automation accounts — Contributor or a custom role is almost always enough. Classic subscription administrator roles are deprecated and I never use them. I also avoid creating dozens of custom roles; if a built-in role covers the need, I use it.

I don't assign roles at management group level unless the scope genuinely requires it. Service principals with permanent passwords get replaced with managed identities. I keep Azure RBAC and resource-level access controls separate to avoid confusion about what's actually enforced.

*/write and */delete in custom roles get a careful second look before I approve them. And I never ignore Deny assignments — they come from Azure Blueprints and override Allow, which has caught me off guard before.

I use groups for all role assignments, use managed identities wherever possible, run access reviews quarterly, and use PIM for just-in-time admin access. Diagnostic logs for role assignment changes are always on. Azure Policy handles enforcement at scale.

Advanced: Privileged Identity Management (PIM)

Requires Azure AD Premium P2:

Benefits:

  • Just-in-time admin access (time-limited)
  • Approval workflows for role activation
  • Access reviews and audit history
  • MFA requirement for role activation

Use Cases:

  • Global Administrator role
  • Owner role at subscription level
  • Any highly privileged role

Monitoring & Compliance

Enable Activity Log for RBAC Changes

# Create alert for role assignment changes
az monitor activity-log alert create \
--name RBACChangeAlert \
--resource-group rg-monitoring \
--condition category=Administrative and operationName=Microsoft.Authorization/roleAssignments/write \
--action-group /subscriptions/{sub-id}/resourceGroups/rg-monitoring/providers/Microsoft.Insights/actionGroups/SecurityTeam

Use Azure Policy to Enforce RBAC Standards

# Example: Audit usage of Owner role
az policy assignment create \
--name 'audit-owner-role' \
--policy '/providers/Microsoft.Authorization/policyDefinitions/10ee2ea2-fb4d-45b8-a7e9-a2e770044cd9' \
--scope /subscriptions/{subscription-id}

Query Role Assignments with Azure Resource Graph

authorizationresources
| where type == "microsoft.authorization/roleassignments"
| extend roleDefinitionId = tostring(properties.roleDefinitionId)
| extend principalId = tostring(properties.principalId)
| join kind=inner (
authorizationresources
| where type == "microsoft.authorization/roledefinitions"
| extend roleDefinitionId = id
| project roleDefinitionId, roleName = tostring(properties.roleName)
) on roleDefinitionId
| where roleName == "Owner"
| project scope, principalId, roleName

Common Patterns

Environment-Based Access

  • Production: Only specific production support group has Contributor
  • Staging: Developers have Contributor
  • Development: Developers have Owner

Application-Based Access

  • App Team: Contributor on their app's resource group
  • Platform Team: Reader on all resource groups, Contributor on shared services
  • Security Team: Security Reader at subscription level

Workload Identity Pattern

# App uses managed identity
resource "azurerm_linux_web_app" "app" {
name = "webapp-myapp"
# ...

identity {
type = "SystemAssigned"
}
}

# Grant managed identity access to resources
resource "azurerm_role_assignment" "app_to_sql" {
scope = azurerm_mssql_server.main.id
role_definition_name = "SQL DB Contributor"
principal_id = azurerm_linux_web_app.app.identity[0].principal_id
}