Executive Snapshot
| Category | Recommendation |
|---|---|
| Permission Model | Principle of least privilege with role-based assignments |
| Role Strategy | Custom roles over built-in when possible, granular scoping |
| Access Reviews | Quarterly automated reviews with PIM integration |
| Monitoring | Real-time alerts for privileged role activations |
| Governance | Conditional Access + PIM for all admin roles |
| Security Score | Target 85%+ Azure Secure Score for identity |
TL;DR
- Implement least privilege by default with custom roles scoped to specific resource groups, not subscriptions
- Enable Privileged Identity Management (PIM) for all administrative roles with time-bounded access and approval workflows
- Automate access reviews quarterly to remove stale permissions and enforce role rotation
- Monitor role assignments with Azure Activity Log analytics and set up real-time alerts for suspicious elevation attempts
- Use Conditional Access policies to enforce MFA and device compliance for privileged operations
Introduction
Azure Role-Based Access Control (RBAC) has evolved significantly since its introduction, but many organizations still struggle with over-privileged users, sprawling permissions, and inadequate governance. With Azure's expanding service portfolio and increasingly sophisticated attack vectors, implementing robust RBAC practices is no longer optional—it's critical for maintaining security posture and compliance.
The stakes are higher in 2026. Standing, over-broad permissions are the quiet enabler behind most cloud privilege-escalation paths: an identity that never needed Owner but has it anyway is an identity an attacker only has to compromise once. Microsoft's own guidance is blunt about it — assign the least privilege the job actually needs, and keep the number of subscription Owners small. Meanwhile, compliance frameworks like SOC 2 Type II and ISO 27001 expect demonstrable access governance with automated controls, not a spreadsheet refreshed once a year.
This guide provides battle-tested Azure RBAC best practices based on real-world implementations across enterprise environments. You'll learn how to design a scalable permission model, implement automated governance controls, and maintain security without hampering productivity.
Prerequisites
- Azure subscription with Global Administrator or Privileged Role Administrator access
- Microsoft Entra ID P2 (or Microsoft Entra ID Governance) license — required for PIM and access reviews
- PowerShell 7.x with the
Azmodules and the Microsoft Graph PowerShell SDK installed (the legacyAzureADandMSOnlinemodules are retired and are not used anywhere in this guide) - Basic understanding of Azure resource hierarchy (Management Groups > Subscriptions > Resource Groups > Resources)
- Familiarity with Azure Policy and Conditional Access concepts
1. Design Your RBAC Strategy
Establish a Permission Hierarchy
Start with Azure's resource scope hierarchy and map your organizational structure to it. The key is granular scoping—Microsoft's RBAC best practices guidance recommends granting the narrowest scope needed, so avoid subscription-level assignments whenever possible.
# List current role assignments to audit existing permissions
az role assignment list --all --output table
# Identify over-privileged assignments (subscription or management group scope)
az role assignment list --scope "/subscriptions/$(az account show --query id -o tsv)" \
--query "[?scope == '/subscriptions/$(az account show --query id -o tsv)']" \
--output table
Create Environment-Specific Resource Groups
Structure your resources to enable proper RBAC scoping:
# Create environment-specific resource groups
SUBSCRIPTION_ID=$(az account show --query id -o tsv)
az group create --name "rg-prod-webapp-001" --location "East US"
az group create --name "rg-dev-webapp-001" --location "East US"
az group create --name "rg-staging-webapp-001" --location "East US"
# Verify resource group creation
az group list --query "[].{Name:name, Location:location}" --output table
2. Implement Custom Roles for Granular Control
Built-in roles are often too broad. Custom roles provide precise permissions aligned to job functions.
Create a Custom Developer Role
{
"Name": "Custom Web App Developer",
"Id": null,
"IsCustom": true,
"Description": "Can manage web apps and related resources but cannot modify networking or security settings",
"Actions": [
"Microsoft.Web/sites/*",
"Microsoft.Web/serverfarms/*",
"Microsoft.Sql/servers/databases/read",
"Microsoft.Storage/storageAccounts/blobServices/containers/read",
"Microsoft.Storage/storageAccounts/blobServices/containers/write",
"Microsoft.Resources/subscriptions/resourceGroups/read",
"Microsoft.Insights/components/*"
],
"NotActions": [
"Microsoft.Web/sites/config/appsettings/write",
"Microsoft.Web/sites/config/connectionstrings/write",
"Microsoft.Authorization/*",
"Microsoft.Network/*"
],
"DataActions": [],
"NotDataActions": [],
"AssignableScopes": [
"/subscriptions/your-subscription-id/resourceGroups/rg-dev-webapp-001",
"/subscriptions/your-subscription-id/resourceGroups/rg-staging-webapp-001"
]
}
Deploy the custom role using PowerShell:
# Save the JSON as custom-developer-role.json, then create the role
$roleDefinition = Get-Content -Path "custom-developer-role.json" | ConvertFrom-Json
$subscriptionId = (Get-AzContext).Subscription.Id
# Update the assignable scopes with your actual subscription ID
$roleDefinition.AssignableScopes = $roleDefinition.AssignableScopes | ForEach-Object {
$_ -replace "your-subscription-id", $subscriptionId
}
# Create the custom role
New-AzRoleDefinition -InputObject $roleDefinition
# Verify role creation
Get-AzRoleDefinition -Name "Custom Web App Developer"
Assign the Custom Role
# Assign custom role to a user for specific resource group
az role assignment create \
--assignee "developer@contoso.com" \
--role "Custom Web App Developer" \
--scope "/subscriptions/$(az account show --query id -o tsv)/resourceGroups/rg-dev-webapp-001"
# Verify assignment
az role assignment list --assignee "developer@contoso.com" --output table
3. Enable Privileged Identity Management (PIM)
Privileged Identity Management is essential for securing administrative roles. It provides just-in-time access, approval workflows, and comprehensive auditing, and requires a Microsoft Entra ID P2 (or Governance) license.
Configure PIM for Built-in Azure Roles
PIM for Azure resource roles is driven by the Az.Resources module — not by the retired AzureAD module, which never covered this surface and which Microsoft has replaced with Microsoft Graph PowerShell anyway. The pattern is: make a principal eligible instead of permanently assigned, then let the role's PIM policy govern activation.
Connect-AzAccount
$subscriptionId = (Get-AzContext).Subscription.Id
$scope = "/subscriptions/$subscriptionId"
# Review what is already eligible at this scope before adding anything
Get-AzRoleEligibilitySchedule -Scope $scope |
Format-Table Name, PrincipalId, RoleDefinitionId, Status
# Grant an eligible (not standing) Contributor assignment, bounded to 180 days
$contributor = Get-AzRoleDefinition -Name "Contributor"
New-AzRoleEligibilityScheduleRequest `
-Name (New-Guid).Guid `
-Scope $scope `
-PrincipalId "<object-id-of-user-or-group>" `
-RoleDefinitionId "$scope/providers/Microsoft.Authorization/roleDefinitions/$($contributor.Id)" `
-RequestType AdminAssign `
-ScheduleInfoStartDateTime (Get-Date -Format o) `
-ExpirationType AfterDuration `
-ExpirationDuration P180D `
-Justification "Quarterly PIM onboarding - eligible, not standing"
Eligibility is only half the control. The activation rules — maximum activation duration, MFA or Conditional Access authentication context on activation, required justification, and required approval — live in the role settings (PIM policy) for that role at that scope. Note that these settings are per role and per resource: what you configure at subscription level is not inherited by a resource group beneath it.
Set Up Approval Workflows
Before you can sensibly design an approval workflow, you need to know who is eligible for what. Older scripts reached for a hand-rolled bearer token against the ARM preview API; that is no longer necessary — Az.Resources ships a supported cmdlet, and its -Filter argument does the interesting work:
function Get-PIMEligibleAssignment {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$SubscriptionId,
# Limit to this exact scope instead of walking up the hierarchy
[switch]$AtScopeOnly
)
$scope = "/subscriptions/$SubscriptionId"
$filter = if ($AtScopeOnly) { "atScope()" } else { $null }
try {
if ($filter) {
Get-AzRoleEligibilitySchedule -Scope $scope -Filter $filter -ErrorAction Stop
}
else {
Get-AzRoleEligibilitySchedule -Scope $scope -ErrorAction Stop
}
}
catch {
Write-Error "Failed to retrieve PIM eligible assignments: $_"
}
}
# Usage — everything eligible at or above the current subscription
$eligibleAssignments = Get-PIMEligibleAssignment -SubscriptionId (Get-AzContext).Subscription.Id
$eligibleAssignments | Format-Table PrincipalId, RoleDefinitionId, Scope, Status
Reading this list requires the Microsoft.Authorization/roleAssignments/read operation at the scope you query. Two other filters are worth knowing: asTarget() returns only what the calling identity is eligible for, and assignedTo('{objectId}') returns a specific principal's eligibilities including those inherited from groups.
4. Implement Automated Access Reviews
Regular access reviews prevent permission creep and ensure compliance with least privilege principles.
Create an Access Review via Microsoft Graph
# Install Microsoft Graph PowerShell if not already installed
# Install-Module Microsoft.Graph -Force
Connect-MgGraph -Scopes "AccessReview.ReadWrite.All"
# Create an access review for Azure resource roles
$accessReviewParams = @{
displayName = "Quarterly Azure RBAC Review - Production Resources"
description = "Review access to production Azure resources"
instanceEnumerationScope = @{
query = "/subscriptions/your-subscription-id/resourceGroups/rg-prod-webapp-001"
queryType = "MicrosoftGraph"
queryRoot = $null
}
scope = @{
"@odata.type" = "#microsoft.graph.accessReviewQueryScope"
query = "/subscriptions/your-subscription-id/resourceGroups/rg-prod-webapp-001/providers/Microsoft.Authorization/roleAssignments"
queryType = "MicrosoftGraph"
queryRoot = $null
}
reviewers = @(
@{
query = "/users/manager@contoso.com"
queryType = "MicrosoftGraph"
}
)
settings = @{
mailNotificationsEnabled = $true
reminderNotificationsEnabled = $true
justificationRequiredOnApproval = $true
defaultDecisionEnabled = $false
defaultDecision = "None"
instanceDurationInDays = 14
recurrence = @{
pattern = @{
type = "absoluteMonthly"
interval = 3 # Quarterly
dayOfMonth = 1
}
range = @{
type = "noEnd"
startDate = (Get-Date).ToString("yyyy-MM-dd")
}
}
}
}
# Create the access review schedule definition
New-MgIdentityGovernanceAccessReviewDefinition -BodyParameter $accessReviewParams
5. Set Up Comprehensive Monitoring and Alerting
Proactive monitoring helps detect unauthorized privilege escalation and suspicious activities.
Create Activity Log Alerts
# Create action group for notifications
az monitor action-group create \
--name "rbac-security-alerts" \
--resource-group "rg-monitoring" \
--short-name "rbac-alerts" \
--email "security@contoso.com" "SecOps Team"
# Create alert rule for role assignment changes
az monitor activity-log alert create \
--name "High-Privilege Role Assignment Alert" \
--resource-group "rg-monitoring" \
--description "Alert when Owner or Contributor roles are assigned" \
--condition category=Administrative \
and operationName=Microsoft.Authorization/roleAssignments/write \
and level=Warning \
--action-groups "rbac-security-alerts" \
--webhook-properties alertname="RBAC_PRIVILEGE_ESCALATION"
Deploy a Log Analytics Workspace Query
# Create Log Analytics workspace if needed
az monitor log-analytics workspace create \
--name "law-security-monitoring" \
--resource-group "rg-monitoring" \
--retention-time 90
# Get workspace ID for queries
WORKSPACE_ID=$(az monitor log-analytics workspace show \
--name "law-security-monitoring" \
--resource-group "rg-monitoring" \
--query customerId -o tsv)
echo "Workspace ID: $WORKSPACE_ID"
Use this KQL query to monitor role assignments:
AzureActivity
| where TimeGenerated > ago(24h)
| where OperationNameValue == "Microsoft.Authorization/roleAssignments/write"
| where ActivityStatusValue == "Success"
| extend RoleAssignmentDetails = parse_json(Properties)
| extend PrincipalId = tostring(RoleAssignmentDetails.entity)
| extend RoleDefinitionId = tostring(RoleAssignmentDetails.roleDefinitionId)
| extend Scope = tostring(RoleAssignmentDetails.scope)
| project TimeGenerated, Caller, PrincipalId, RoleDefinitionId, Scope, ResourceGroup
| join kind=inner (
AzureActivity
| where OperationNameValue == "Microsoft.Authorization/roleDefinitions/read"
| extend RoleDefinitionId = tostring(Properties.roleDefinitionId)
| extend RoleName = tostring(Properties.roleName)
| project RoleDefinitionId, RoleName
) on RoleDefinitionId
| where RoleName in ("Owner", "Contributor", "User Access Administrator")
| sort by TimeGenerated desc
6. Implement Conditional Access for Privileged Operations
Enforce additional security controls for administrative actions.
Create a Conditional Access Policy
# Connect to Microsoft Graph
Connect-MgGraph -Scopes "Policy.ReadWrite.ConditionalAccess"
# Define Conditional Access policy for Azure management
$conditionalAccessPolicy = @{
displayName = "Require MFA for Azure Management"
state = "enabled"
conditions = @{
applications = @{
includeApplications = @("797f4846-ba00-4fd7-ba43-dac1f8f63013") # Azure Management API
}
users = @{
includeRoles = @(
"62e90394-69f5-4237-9190-012177145e10", # Global Administrator
"f28a1f50-f6e7-4571-818b-6a12f2af6b6c", # SharePoint Administrator
"b1be1c3e-b65d-4f19-8427-f6fa0d97feb9" # Conditional Access Administrator
)
}
clientAppTypes = @("all")
locations = @{
includeLocations = @("All")
excludeLocations = @("AllTrusted")
}
}
grantControls = @{
operator = "AND"
builtInControls = @("mfa", "compliantDevice")
}
sessionControls = @{
signInFrequency = @{
value = 4
type = "hours"
isEnabled = $true
}
}
}
# Create the policy
New-MgIdentityConditionalAccessPolicy -BodyParameter $conditionalAccessPolicy
Troubleshooting Common Issues
Issue 1: "Insufficient privileges" Error Despite Correct Role
Symptom: User receives access denied errors even with appropriate role assignments.
Solution: Check for policy restrictions and propagation delays.
# Check effective permissions for a user
az role assignment list --assignee "user@contoso.com" --all --include-inherited
# Verify Azure Policy isn't blocking the action
az policy assignment list --scope "/subscriptions/$(az account show --query id -o tsv)" \
--query "[?enforcementMode=='Default']"
Issue 2: PIM Activation Fails
Symptom: Users cannot activate eligible roles through PIM.
Solution: Verify licensing and approval workflows. If your existing runbook does this with Get-AzureADSubscribedSku, it will fail — that module is retired; the Graph PowerShell equivalents are below.
Connect-MgGraph -Scopes "Organization.Read.All", "Application.Read.All"
# Check for a Microsoft Entra ID P2 entitlement. P2 ships standalone and inside
# bundles (EMS E5, Microsoft 365 E5), so match on the AAD_PREMIUM_P2 *service
# plan* rather than only on the SKU part number.
Get-MgSubscribedSku -All |
Where-Object { $_.ServicePlans.ServicePlanName -contains "AAD_PREMIUM_P2" } |
Select-Object SkuPartNumber,
ConsumedUnits,
@{ Name = "Purchased"; Expression = { $_.PrepaidUnits.Enabled } }
# Verify the PIM service principal exists in the tenant
$pimServicePrincipal = Get-MgServicePrincipal -Filter "displayName eq 'MS-PIM'"
if (-not $pimServicePrincipal) {
Write-Error "PIM service principal not found. Ensure PIM is properly enabled."
}
Issue 3: Custom Role Assignment Failures
Symptom: Cannot assign custom roles to users or groups.
Solution: Verify role definition and assignable scopes.
# Validate custom role definition
az role definition list --custom-role-only --output table
# Check if role is assignable at the target scope
az role definition show --name "Custom Web App Developer" \
--query "assignableScopes" --output table
Key Takeaways
- Scope assignments granularly to resource groups rather than subscriptions to minimize blast radius
- Custom roles provide superior security compared to overly broad built-in roles for most scenarios
- PIM is mandatory for administrative roles to enforce just-in-time access and approval workflows
- Automated access reviews every 90 days prevent permission sprawl and ensure compliance
- Real-time monitoring and alerting help detect and respond to unauthorized privilege escalation attempts
- Conditional Access policies should enforce MFA and device compliance for all privileged Azure operations
- Regular auditing using KQL queries provides visibility into role assignment patterns and anomalies
Next Steps
- Conduct an RBAC audit of your current environment using the scripts provided in this guide
- Implement PIM for all administrative roles, starting with Global Administrator and Contributor
- Set up automated access reviews for your most critical resource groups and subscriptions
- Deploy monitoring solutions with the KQL queries to establish baseline security visibility
- Review Azure Secure Score recommendations related to identity and access management
Related Articles
- Conditional Access Gap Analysis: Finding and Fixing Coverage Holes with What-If — proving the policies in section 6 actually cover the admins you think they cover
- Zero Trust Network Architecture: A Practical Azure Implementation Guide — the network half of the model RBAC enforces on the identity side
- Entra ID Workload Identity Federation: Eliminating Secrets in CI/CD Pipelines — least privilege for the non-human principals your pipelines assign roles to
Leave a Reply