This page covers Microsoft Azure identity hardening across the surfaces that determine whether an attacker who lands a single credential can pivot to tenant-wide compromise. The Azure identity story is centred on Microsoft Entra ID (formerly Azure Active Directory; the legacy short-form name appears in older tooling and CIS section titles but every new document, including this one, must use the current name). Unlike AWS, Azure does not ship a separate single-sign-on product: Entra ID is the identity provider, the directory, the federation engine, and the source of truth for role assignments. Scope here is the Azure commercial cloud; the sovereign clouds (Azure Government, Azure China operated by 21Vianet, Azure for US Department of Defense) inherit the same control inventory but require region-specific Graph endpoints and have their own compliance benchmarks — see threat-model for sovereign-cloud caveats.
The mental model: Azure exposes three distinct privilege primitives that the rest of this page hardens. First, directory roles in Entra ID — Global Administrator, Privileged Role Administrator, Conditional Access Administrator, and the rest of the ≈90 built-in roles — govern who can change identity, policy, and tenant settings. Second, Privileged Identity Management (PIM) is the just-in-time activation layer that converts standing role membership into time-bound, approval-gated, audit-logged eligibility; PIM is the privilege primitive on Azure the same way that role-assumption is on AWS. Third, Conditional Access (CA) is the tenant-wide enforcement primitive — every authentication flowing through Entra ID is evaluated against the CA policy set, so CA is where you require MFA, block legacy protocols, restrict device state, and enforce session controls. Azure RBAC at the subscription/resource-group/resource scope is a separate layer that authorises actions against the management plane; it draws principals from Entra ID but is not the focus of this page (it surfaces in General IAM — privileged access and again in Phase 6 Azure domain pages).
Order matters. Controls 01–04 are the CRITICAL and HIGH preventive controls that close the largest standing risks in nearly every audited tenant: too many Global Admins, no break-glass plan, no JIT on privileged roles, and no enforced MFA at the front door. Controls 05–08 progressively close the remaining identity attack surface — legacy authentication protocols (where MFA cannot be enforced), service-principal secret sprawl, OAuth consent grants weaponised by Midnight Blizzard in 2024, and guest-account drift in tenants that accept B2B collaboration. The compliance rows throughout cite CIS Microsoft Azure Foundations Benchmark v3.0.0 (February 2025 release), NIST SP 800-53 rev5, ISO/IEC 27001:2022, ISO/IEC 27017:2015, and — where the control implements an executive-branch baseline — CISA Binding Operational Directive 25-01 and the SCuBA Microsoft 365 secure configuration baselines. Equivalence callouts at the bottom of each control point to the matching control on the AWS, GCP, and OCI pages so a reader can compare modelling across providers. Cross-cutting principles (MFA, secrets, privileged access, audit logging) live in General IAM — MFA and General IAM — privileged access; this page maps them to Entra ID primitives.
One authoring note that affects nearly every control on this page: the az CLI does not have first-class verbs for Conditional Access policies, authorization policies, or several Entra ID governance objects. The canonical pattern is az rest --method POST --uri https://graph.microsoft.com/v1.0/... against Microsoft Graph, with the policy body supplied as a JSON document. This is documented inline in azure-iam-04 and reused in 05, 07, and 08; readers transcribing the snippets should expect to authenticate az against a tenant whose signed-in user holds Conditional Access Administrator or Global Administrator (the latter only when no other role suffices).
Limit Global Administrators to between two and four named cloud-only accounts; every other privileged operation must flow through a more specific role (Privileged Role Administrator, User Administrator, Conditional Access Administrator, Application Administrator, etc.) activated through PIM rather than held as a standing assignment. Microsoft's RBAC best-practice guidance explicitly recommends "less than 5" Global Administrators and calls the role "the highest privileged role in Microsoft Entra ID" (Microsoft Entra ID — RBAC best practices (accessed 2026-05)). The principle is reinforced in General IAM — privileged access; the severity derivation follows the worked example in methodology EX-MFA-01 applied to a single-step path to tenant takeover.
Remediation — Azure CLI / Microsoft Graph
# Enumerate current Global Administrators. The Graph directoryRoles endpoint
# returns the activated instance of the role; use roleTemplateId to find it
# even when the friendly id differs across tenants.
GA_TEMPLATE_ID="62e90394-69f5-4237-9190-012177145e10" # Global Administrator
GA_ROLE_ID=$(az rest --method GET \
--uri "https://graph.microsoft.com/v1.0/directoryRoles?\$filter=roleTemplateId eq '${GA_TEMPLATE_ID}'" \
--query 'value[0].id' -o tsv)
az rest --method GET \
--uri "https://graph.microsoft.com/v1.0/directoryRoles/${GA_ROLE_ID}/members" \
--query 'value[].{upn:userPrincipalName,id:id,type:"@odata.type"}' \
--output table
# Expected: 2–4 named, cloud-only (no on-prem sync) accounts. Any guest
# (#EXT#) or synced (onPremisesSyncEnabled=true) account in this list is an
# immediate finding.
Remediation — Terraform
# Terraform AzureRM provider ~> 3.0
# (Directory role assignments live in the AzureAD provider; AzureRM is pinned
# for the resource-plane controls later on this page.)
terraform {
required_providers {
azuread = { source = "hashicorp/azuread", version = "~> 2.50" }
}
}
# Pin the Global Administrator role assignments to a small set of named admins.
# Any drift (a 5th assignment appearing) is detected on the next `terraform plan`.
data "azuread_directory_role" "global_admin" {
display_name = "Global Administrator"
}
resource "azuread_directory_role_assignment" "ga" {
for_each = toset(var.global_admin_object_ids) # 2–4 entries only
role_id = data.azuread_directory_role.global_admin.template_id
principal_object_id = each.value
}
Remediation — Bicep
targetScope = 'managementGroup'
@description('Object IDs of the <=5 designated Global Administrators (break-glass + named owners).')
param globalAdminObjectIds array
@description('Entra ID role definition id for Global Administrator.')
var globalAdminRoleId = '62e90394-69f5-4237-9190-012177145e10'
resource gaAssignments 'Microsoft.Authorization/roleAssignments@2024-04-01' = [for (oid, i) in globalAdminObjectIds: {
name: guid(managementGroup().id, oid, globalAdminRoleId)
properties: {
roleDefinitionId: tenantResourceId('Microsoft.Authorization/roleDefinitions', globalAdminRoleId)
principalId: oid
principalType: 'User'
}
}]
output enforcedGlobalAdminCount int = length(globalAdminObjectIds)
Remediation — Pulumi (TypeScript)
import * as pulumi from "@pulumi/pulumi";
import * as authorization from "@pulumi/azure-native/authorization";
// Entra ID built-in role: Global Administrator
const globalAdminRoleId = "62e90394-69f5-4237-9190-012177145e10";
const designated = [
"00000000-0000-0000-0000-000000000001", // break-glass-1
"00000000-0000-0000-0000-000000000002", // break-glass-2
// <= 5 named owners total
];
designated.forEach((principalId, i) => {
new authorization.RoleAssignment(`ga-${i}`, {
scope: "/providers/Microsoft.Management/managementGroups/<tenant-root>",
principalId,
principalType: "User",
roleDefinitionId: `/providers/Microsoft.Authorization/roleDefinitions/${globalAdminRoleId}`,
});
});
Compliance mapping
CIS AWS Foundations v3.0.0
CIS Microsoft Azure Foundations v3.0.0
CIS GCP Foundation v4.0.0
CIS OCI Foundation v2.0.0
NIST SP 800-53 rev5
ISO/IEC 27001:2022
ISO/IEC 27017:2015
1.5
1.1.1
1.1
1.1
AC-6(7); AC-2
A.5.15; A.5.16
n/a
Log signals
AzureActivity OperationNameValue = "Microsoft.Authorization/roleAssignments/write" entries where the assigned role definition matches Global Administrator, Privileged Role Administrator, or User Access Administrator.
SigninLogs entries where the resolved UserPrincipalName sits in the tenant Global Administrator directory role and the sign-in is interactive from outside the documented admin-jump-host network.
AzureDiagnostics category = "DirectoryRoleManagement" deltas tracking the standing-Global-Administrator population over time.
Query
AzureActivity
| where OperationNameValue == "Microsoft.Authorization/roleAssignments/write"
| where Properties has "Global Administrator" or Properties has "Privileged Role Administrator" or Properties has "User Access Administrator"
| project TimeGenerated, Caller, CallerIpAddress, ResourceGroup, ActivityStatusValue, Properties
| order by TimeGenerated desc
| take 200
Run as a KQL query in Log Analytics against the workspace receiving the tenant AzureActivity diagnostic export. Persist as a Sentinel analytics rule once the false-positive rate from PIM elevations is calibrated.
Alert threshold
Any privileged role (Owner / Contributor / User Access Administrator / Global Administrator) assigned to a non-PIM-eligible Caller within a 24h window — page on first occurrence.
Tune per environment baseline using Log Analytics KQL summarize count() by Caller over a 30-day calibration window before promoting to an analytics rule.
Initial response
Verify the assignment against the documented PIM justification ticket and the named Caller; if no ticket exists, treat as confirmed compromise.
Roll back via Entra PIM access review: revoke the standing assignment, force the recipient to re-elevate through PIM with MFA and ticket-bound justification, and rotate any tokens the Caller principal issued in the prior 24h.
Escalate per general/ir.html — open an incident, capture AzureActivity + SigninLogs + AuditLogs for the affected tenant slice, and confirm the privileged-access workstation policy and conditional-access baselines remain enforced.
Provision at least two break-glass (emergency access) accounts that are cloud-only (no on-prem AD synchronisation), excluded from the standard Conditional Access policies that could lock them out, and protected by FIDO2 hardware security keys rather than the TOTP/Authenticator app used by ordinary users. Credentials are stored offline, sign-ins are monitored continuously, and the accounts are tested on a documented cadence. Microsoft's authoritative guidance is the "Manage emergency access admin accounts" article (Microsoft Entra ID — emergency access admin accounts (accessed 2026-05)) and the pattern is referenced in General IAM — privileged access.
Remediation — Azure CLI / Microsoft Graph
# 1. Create the cloud-only break-glass user (no on-prem sync; explicit
# onmicrosoft.com UPN so the account survives custom-domain takedowns).
TENANT_DOMAIN="contoso.onmicrosoft.com"
az ad user create \
--display-name "Break-Glass 01" \
--user-principal-name "breakglass01@${TENANT_DOMAIN}" \
--password "$(openssl rand -base64 32)" \
--force-change-password-next-sign-in false
# 2. Add to the emergency-access security group that is EXCLUDED from the
# tenant-wide MFA Conditional Access policy.
EA_GROUP_ID=$(az ad group show --group "emergency-access-exclusions" \
--query id -o tsv)
BG_USER_ID=$(az ad user show --id "breakglass01@${TENANT_DOMAIN}" \
--query id -o tsv)
az ad group member add --group "$EA_GROUP_ID" --member-id "$BG_USER_ID"
# 3. Verify the user does NOT appear in the standard MFA policy's includeUsers
# and DOES appear in its excludeGroups (Conditional Access policies via Graph).
az rest --method GET \
--uri "https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies" \
--query "value[?displayName=='Require MFA for all users'].conditions.users"
targetScope = 'tenant'
@description('UPN of the cloud-only break-glass account (does NOT federate from on-prem AD).')
param breakGlassUpn string
@description('Conditional Access policy excludes this account so an IdP outage cannot lock out admins.')
param caExclusionGroupId string
// Reference existing user (created out-of-band via Graph API; do not author secrets in IaC)
resource bg 'Microsoft.Graph/users@2023-09-01' existing = {
name: breakGlassUpn
}
resource caExclude 'Microsoft.Graph/groups/members@2023-09-01' = {
name: '${caExclusionGroupId}/${bg.id}'
}
output breakGlassObjectId string = bg.id
Compliance mapping
CIS AWS Foundations v3.0.0
CIS Microsoft Azure Foundations v3.0.0
CIS GCP Foundation v4.0.0
CIS OCI Foundation v2.0.0
NIST SP 800-53 rev5
ISO/IEC 27001:2022
ISO/IEC 27017:2015
1.4
1.1.3
best-practices
best-practices
CP-2; AC-2
A.5.29; A.5.16
n/a
Log signals
SigninLogs entries where UserPrincipalName matches the documented break-glass account naming pattern (e.g. breakglass-*@tenant.onmicrosoft.com) — every authentication on these accounts is by definition incident-grade.
AuditLogs Category = "UserManagement" showing password rotation, MFA method changes, or role removal applied to break-glass principals outside the documented quarterly drill schedule.
AzureActivity OperationNameValue = "Microsoft.Authorization/roleAssignments/delete" targeting the Global Administrator assignment on break-glass principals — would silently disarm the emergency path.
Query
SigninLogs
| where UserPrincipalName startswith "breakglass-"
| project TimeGenerated, UserPrincipalName, ResultType, ResultDescription, IPAddress, AppDisplayName, ClientAppUsed, ConditionalAccessStatus
| order by TimeGenerated desc
| take 200
Run as a KQL query in Log Analytics scoped to the tenant SigninLogs export. Promote to a Sentinel analytics rule with severity High once the break-glass naming convention is enforced via Entra ID administrative-unit policy.
Alert threshold
Any successful sign-in on a break-glass principal — page on first occurrence regardless of source IP.
Three or more failed sign-ins on a break-glass principal within a 1h window — indicates targeted credential-guessing and warrants account-state inspection plus a rotation event.
Initial response
Confirm the sign-in correlates with a declared incident ticket; if no ticket exists, treat as confirmed compromise of the highest-privilege identity in the tenant.
Revoke all refresh tokens for the break-glass principal via Revoke-MgUserSignInSession, rotate the password to a fresh hardware-managed value, and re-pair the FIDO2 key under four-eyes witness.
Escalate per general/ir.html — capture SigninLogs + AuditLogs + AzureActivity for the affected tenant slice over the prior 72h, and confirm that the conditional-access exclusion for the break-glass group remains scoped to the documented IP allow-list.
Every privileged Entra ID role (Global Administrator, Privileged Role Administrator, Conditional Access Administrator, Application Administrator, Security Administrator, plus the privileged Azure RBAC roles Owner / User Access Administrator at the root management group) must be held as a PIM eligible assignment, not an active standing assignment. Activating an eligible assignment requires re-authentication with MFA, optional approval, an activation justification, and a maximum activation window (typically 1–8 hours), and writes an audit log entry — converting a stolen credential's value from indefinite to bounded. Microsoft's authoritative documentation covers configuration and the role-management best-practice baseline (Microsoft Entra Privileged Identity Management documentation (accessed 2026-05)); MCSB control IM-1 ("Use centralized identity and authentication system") cites PIM as the just-in-time enforcement layer.
Remediation — Azure CLI / Microsoft Graph
# Convert an active Global Administrator assignment into an eligible (PIM) one.
# This is a two-step Graph call: read the active assignment, then create an
# eligibility schedule request with action=adminAssign.
USER_ID=$(az ad user show --id "alice@contoso.com" --query id -o tsv)
GA_TEMPLATE_ID="62e90394-69f5-4237-9190-012177145e10"
# Create the eligibility (PIM eligible role assignment) via Graph.
az rest --method POST \
--uri "https://graph.microsoft.com/v1.0/roleManagement/directory/roleEligibilityScheduleRequests" \
--body "$(cat <<JSON
{
"action": "adminAssign",
"justification": "Bootstrap PIM eligibility for alice@contoso.com (replaces standing GA)",
"roleDefinitionId": "${GA_TEMPLATE_ID}",
"directoryScopeId": "/",
"principalId": "${USER_ID}",
"scheduleInfo": {
"startDateTime": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"expiration": { "type": "noExpiration" }
}
}
JSON
)"
# Remove the now-redundant ACTIVE assignment so the user must activate to use it.
az rest --method DELETE \
--uri "https://graph.microsoft.com/v1.0/directoryRoles/${GA_ROLE_ID}/members/${USER_ID}/\$ref"
Remediation — Terraform
# Terraform AzureRM provider ~> 3.0
# PIM eligibility is exposed via azuread_privileged_access_group_*. For
# directory-role-level PIM at scale, the canonical pattern is to gate role
# membership on a privileged-access group whose eligibility schedule the
# resource below manages.
resource "azuread_group" "ga_eligible" {
display_name = "PIM-Eligible-Global-Administrators"
security_enabled = true
assignable_to_role = true
}
# Bind the role to the group, then expose group membership as a PIM eligibility.
resource "azuread_directory_role_assignment" "ga_via_group" {
role_id = "62e90394-69f5-4237-9190-012177145e10"
principal_object_id = azuread_group.ga_eligible.object_id
}
resource "azuread_privileged_access_group_eligibility_schedule" "ga_eligibility" {
for_each = toset(var.ga_eligible_user_object_ids)
principal_id = each.value
group_id = azuread_group.ga_eligible.object_id
assignment_type = "member"
duration = "P365D"
justification = "Eligible Global Administrator via PIM"
}
Remediation — Bicep
targetScope = 'managementGroup'
@description('Entra ID role to enable PIM activation for (e.g. Global Administrator).')
param roleDefinitionId string
@description('Principal eligible for just-in-time activation.')
param principalId string
resource pimEligible 'Microsoft.Authorization/roleEligibilityScheduleRequests@2024-09-01-preview' = {
name: guid(managementGroup().id, principalId, roleDefinitionId, 'eligible')
properties: {
principalId: principalId
roleDefinitionId: tenantResourceId('Microsoft.Authorization/roleDefinitions', roleDefinitionId)
requestType: 'AdminAssign'
scheduleInfo: {
expiration: {
type: 'NoExpiration'
}
}
justification: 'PIM eligible — MFA + approval required at activation'
}
}
Remediation — Pulumi (TypeScript)
import * as pulumi from "@pulumi/pulumi";
import * as authorization from "@pulumi/azure-native/authorization";
// PIM eligible role assignment (just-in-time elevation).
new authorization.RoleEligibilityScheduleRequest("ga-eligible", {
scope: "/providers/Microsoft.Management/managementGroups/<tenant-root>",
principalId: "<entra-user-object-id>",
roleDefinitionId: "/providers/Microsoft.Authorization/roleDefinitions/62e90394-69f5-4237-9190-012177145e10",
requestType: "AdminAssign",
scheduleInfo: {
expiration: { type: "NoExpiration" },
},
justification: "PIM eligible — MFA + approval required at activation",
});
Compliance mapping
CIS AWS Foundations v3.0.0
CIS Microsoft Azure Foundations v3.0.0
CIS GCP Foundation v4.0.0
CIS OCI Foundation v2.0.0
NIST SP 800-53 rev5
ISO/IEC 27001:2022
ISO/IEC 27017:2015
1.21
1.1.4
best-practices
best-practices
AC-6(1); AC-6(2); AC-2(7)
A.5.15
n/a
Log signals
AuditLogs Category = "RoleManagement" entries where ActivityDisplayName = "Add member to role completed (PIM activation)" — establishes the authoritative ledger of elevation events; absence of an entry beside a privileged AzureActivity write indicates the PIM gate was bypassed.
IdentityInfo deltas adding a principal directly to a built-in directory role (Global Administrator, Privileged Role Administrator) as a permanent assignment instead of an eligible one.
AzureActivity privileged write events whose Caller has no concurrent PIM activation in the prior 90 minutes — the elevation window is the legitimate signal pattern.
Query
AuditLogs
| where Category == "RoleManagement"
| where ActivityDisplayName has "Add member to role" and TargetResources has "Permanent"
| extend role = tostring(TargetResources[0].displayName), target = tostring(TargetResources[2].userPrincipalName)
| project TimeGenerated, ActivityDisplayName, role, target, InitiatedBy, Result
| order by TimeGenerated desc
| take 200
Run as a KQL query against the Log Analytics workspace ingesting Entra AuditLogs. Schedule as a Sentinel analytics rule with severity High; correlate with PIM activation absence over a 90-minute window to suppress legitimate emergency-elevation noise.
Alert threshold
Any permanent assignment to a privileged Entra role — page immediately; the policy intent is that all such assignments flow through PIM as eligible.
Five or more PIM activations by the same Caller within a rolling 24h period — investigate for credential abuse or automation that should be re-platformed onto a managed identity.
Initial response
Reverse the permanent assignment via the Entra portal Role Settings blade or Update-MgRoleManagementDirectoryRoleAssignment; re-issue as PIM-eligible with the documented approval flow.
Walk back the assigner's recent AuditLogs trail to confirm whether this is a one-shot policy gap or an attempt to bypass PIM systematically; if patterns repeat across multiple principals, treat the assigner as a compromised admin.
Escalate per general/ir.html — capture the AuditLogs + IdentityInfo snapshot for the affected role, and revalidate PIM policy enforcement (approver list, max activation duration, MFA requirement) against the tenant baseline.
Deploy a Conditional Access policy that requires multi-factor authentication on every interactive sign-in for every user (phishing-resistant FIDO2 / Windows Hello / certificate-based authentication is strongly preferred over SMS or TOTP — see General IAM — MFA for the severity derivation in methodology EX-MFA-01). Exclude only the documented break-glass accounts from azure-iam-02. CIS Microsoft Azure Foundations Benchmark v3.0.0 section 1.1.2 codifies this requirement; CISA Binding Operational Directive 25-01 and the SCuBA Microsoft 365 baseline likewise mandate it for federal civilian agencies (CISA Binding Operational Directive 25-01 — SCuBA baselines (accessed 2026-05)). Note an important authoring detail: the az CLI lacks first-class verbs for Conditional Access, so policies are authored against Microsoft Graph via az rest.
Remediation — Azure CLI / Microsoft Graph
# Conditional Access policies cannot be authored with first-class `az` verbs;
# `az rest` against the Microsoft Graph beta or v1.0 endpoint is the
# documented pattern. The JSON body below targets All Users (excluding the
# break-glass group), all cloud apps, all client app types, and requires the
# built-in 'mfa' grant control. State 'enabled' applies it immediately.
cat > ca-policy-require-mfa.json <<'JSON'
{
"displayName": "CA001 — Require MFA for all users",
"state": "enabled",
"conditions": {
"users": {
"includeUsers": ["All"],
"excludeGroups": ["<EMERGENCY_ACCESS_GROUP_OBJECT_ID>"]
},
"applications": { "includeApplications": ["All"] },
"clientAppTypes": ["all"]
},
"grantControls": {
"operator": "OR",
"builtInControls": ["mfa"]
}
}
JSON
az rest --method POST \
--uri https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies \
--body @ca-policy-require-mfa.json \
--headers "Content-Type=application/json"
# Verify enabled state.
az rest --method GET \
--uri https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies \
--query "value[?displayName=='CA001 — Require MFA for all users'].state"
Remediation — Terraform
# Terraform AzureRM provider ~> 3.0
# (Conditional Access lives in azuread provider; azuread is referenced
# alongside AzureRM in the providers block for this page's controls.)
resource "azuread_conditional_access_policy" "require_mfa_all_users" {
display_name = "CA001 — Require MFA for all users"
state = "enabled"
conditions {
client_app_types = ["all"]
applications {
included_applications = ["All"]
}
users {
included_users = ["All"]
excluded_groups = [azuread_group.emergency_access_exclusions.object_id]
}
}
grant_controls {
operator = "OR"
built_in_controls = ["mfa"]
}
}
import * as pulumi from "@pulumi/pulumi";
import * as msgraph from "@pulumi/azure-native/authorization";
// Conditional Access policy authored via Microsoft Graph (azure-native preview surface).
// Excludes the break-glass group so a Conditional Access misconfiguration cannot lock out admins.
const policy = {
displayName: "require-mfa-all-users",
state: "enabled",
conditions: {
users: { includeUsers: ["All"], excludeGroups: ["<break-glass-group-object-id>"] },
applications: { includeApplications: ["All"] },
},
grantControls: { operator: "AND", builtInControls: ["mfa"] },
};
export const conditionalAccessPolicy = policy;
Compliance mapping
CIS AWS Foundations v3.0.0
CIS Microsoft Azure Foundations v3.0.0
CIS GCP Foundation v4.0.0
CIS OCI Foundation v2.0.0
NIST SP 800-53 rev5
ISO/IEC 27001:2022
ISO/IEC 27017:2015
1.10
1.1.2
1.2
1.7
IA-2(1)
A.5.17; A.8.5
n/a
Log signals
SigninLogs entries where ConditionalAccessStatus = "notApplied" on a high-privilege resource (Microsoft Graph, Azure Resource Manager) — indicates a coverage gap in the policy scope.
AuditLogs Category = "Policy" with ActivityDisplayName = "Update conditional access policy" where the diff reduces state from enabled to disabled or removes the requireMfa grant control.
AzureDiagnostics category = "ConditionalAccessPolicyEvaluation" entries showing skipped MFA challenge for a principal in scope of the baseline — exception report that should map to documented break-glass principals only.
Query
SigninLogs
| where AuthenticationRequirement == "singleFactorAuthentication"
| where ResourceDisplayName in ("Microsoft Graph", "Windows Azure Service Management API")
| where ConditionalAccessStatus != "success"
| project TimeGenerated, UserPrincipalName, AppDisplayName, ResourceDisplayName, IPAddress, ConditionalAccessStatus, ConditionalAccessPolicies
| order by TimeGenerated desc
| take 200
Run as a KQL query in Log Analytics; high-privilege resources should never authenticate with a single factor outside the documented break-glass exception. Persist as a Sentinel analytics rule once the legitimate exception list is encoded as a watchlist.
Alert threshold
Any single-factor sign-in to Microsoft Graph or Azure Resource Manager by a non-break-glass principal — page on first occurrence.
Conditional access policy edit that disables an enabled baseline or strips the MFA grant control — page on the edit itself, before the next sign-in cycle materialises the regression.
Initial response
Restore the policy via the policy version history (Entra portal → Conditional Access → policy → Modified) or apply the IaC baseline; capture the AuditLogs InitiatedBy identity as the policy mutator of record.
Force a sign-out for any principal that authenticated single-factor during the exposure window — Revoke-MgUserSignInSession by UserId list — and require re-authentication under the restored baseline.
Escalate per general/ir.html — open an incident if any privileged write was issued during the exposure window, and reconfirm the conditional-access What-If simulation against a canonical privileged-role principal.
Deploy a Conditional Access policy that blocks legacy authentication — POP3, IMAP4, SMTP AUTH, MAPI, Exchange Web Services, and the "Other Clients" client app types that lump together every protocol that does not support modern authentication. Legacy auth is the principal bypass for the MFA control in azure-iam-04: protocols that pre-date OAuth 2.0 cannot present a second factor, so even with MFA mandated for all users the attacker who finds a username/password can still sign in via IMAP. Microsoft retired Basic Authentication for Exchange Online in 2022–2023 but tenant-level legacy auth remains reachable via SMTP AUTH and via on-prem hybrid scenarios; CIS Microsoft Azure Foundations Benchmark v3.0.0 section 1.1.5 mandates the explicit block.
Remediation — Azure CLI / Microsoft Graph
# Block legacy authentication via Conditional Access. clientAppTypes filters
# to exactly the legacy stacks: exchangeActiveSync (legacy variant) and 'other'
# (POP, IMAP, SMTP AUTH, MAPI, RPC, EWS, autodiscover with basic auth, etc).
cat > ca-policy-block-legacy.json <<'JSON'
{
"displayName": "CA002 — Block legacy authentication",
"state": "enabled",
"conditions": {
"users": {
"includeUsers": ["All"],
"excludeGroups": ["<EMERGENCY_ACCESS_GROUP_OBJECT_ID>"]
},
"applications": { "includeApplications": ["All"] },
"clientAppTypes": ["exchangeActiveSync", "other"]
},
"grantControls": {
"operator": "OR",
"builtInControls": ["block"]
}
}
JSON
az rest --method POST \
--uri https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies \
--body @ca-policy-block-legacy.json \
--headers "Content-Type=application/json"
# Sanity-check: query the sign-in logs for any legacy auth attempts in the
# last 24h that succeeded BEFORE the block was deployed (these are likely
# active service accounts that will break and need migration).
az rest --method GET \
--uri "https://graph.microsoft.com/beta/auditLogs/signIns?\$filter=clientAppUsed eq 'IMAP4' or clientAppUsed eq 'SMTP' or clientAppUsed eq 'POP3'" \
--query 'value[].{user:userPrincipalName,app:clientAppUsed,result:status.errorCode}'
SigninLogs entries where ClientAppUsed is one of the legacy protocols (POP, IMAP, SMTP, Other clients, Exchange ActiveSync, Authenticated SMTP) — these are the explicit denylist that the baseline policy is built around.
AuditLogs showing a conditional-access policy edit that excludes a user, group, or app from the legacy-auth block policy — coverage erosion event.
SigninLogs spike in ResultType = 50126 (invalid credentials) tied to legacy ClientAppUsed values — adversary baseline-fingerprinting before pivoting to modern endpoints.
Query
SigninLogs
| where ClientAppUsed in ("POP", "IMAP", "SMTP", "Other clients", "Exchange ActiveSync", "Authenticated SMTP")
| where ResultType == 0
| project TimeGenerated, UserPrincipalName, ClientAppUsed, AppDisplayName, IPAddress, Location, ResultType
| order by TimeGenerated desc
| take 200
Run the KQL query in Log Analytics against SigninLogs. A successful legacy-auth sign-in on a tenant with the baseline policy enabled is a coverage failure, not a tuning concern; persist as a Sentinel analytics rule with severity High.
Alert threshold
Any successful legacy-auth sign-in by a user in the baseline-policy scope — page on first occurrence.
Twenty or more failed legacy-auth attempts from the same IP in a rolling 1h window — adversary scanning for non-MFA accessible identities; block at the firewall and add the source to the named-location risky-IP list.
Initial response
Disable the offending account's legacy-auth client and rotate its credential; if the account is a service identity, migrate to a managed identity or app registration with modern auth.
Walk the user's mailbox-rules and forwarding configuration via Exchange Online PowerShell — legacy-auth compromise frequently lands rule-based exfiltration of inbound mail.
Escalate per general/ir.html — confirm the baseline conditional-access policy scope still includes the affected principal, and capture the SigninLogs source-IP for inclusion in the tenant named-locations risky-IP list.
Azure-specific control — legacy POP/IMAP/SMTP basic-auth block has no exact cross-provider sibling; closest parallels are the MFA-enforcement controls. Equivalent on: AWS · GCP · OCI
azure-iam-06-managed-identity!HIGHPREVENTIVE
Replace service principal client secrets and certificates with managed identities wherever the consumer is an Azure resource (Virtual Machine, App Service, Function, Container Apps, AKS pod with workload identity, Logic App, Data Factory, etc.). A managed identity has no secret to leak: the Azure resource fabric injects a short-lived token on demand through the Instance Metadata Service or the workload identity federation endpoint. This is Azure's equivalent of AWS instance roles and GCP Workload Identity Federation, and the Microsoft Cloud Security Benchmark control IM-3 ("Manage application identities securely and automatically") explicitly recommends managed identity over service principal secrets (Microsoft Cloud Security Benchmark — Identity Management (accessed 2026-05)). The same principle is treated cross-provider in General IAM — secrets management.
Remediation — Azure CLI
# Create a user-assigned managed identity that multiple resources can share.
az identity create \
--resource-group rg-prod-shared \
--name mi-prod-app-reader \
--location westeurope
MI_PRINCIPAL_ID=$(az identity show \
--resource-group rg-prod-shared \
--name mi-prod-app-reader \
--query principalId -o tsv)
# Grant the managed identity the role it actually needs (least privilege).
az role assignment create \
--assignee-object-id "$MI_PRINCIPAL_ID" \
--assignee-principal-type ServicePrincipal \
--role "Key Vault Secrets User" \
--scope $(az keyvault show --name kv-prod-app --query id -o tsv)
# Attach the managed identity to an App Service; the runtime obtains tokens
# via the IDENTITY_ENDPOINT environment variable — no client secret needed.
az webapp identity assign \
--resource-group rg-prod-shared \
--name app-prod-web \
--identities $(az identity show \
--resource-group rg-prod-shared \
--name mi-prod-app-reader --query id -o tsv)
AzureActivity OperationNameValue = "Microsoft.Web/sites/write" or "Microsoft.Compute/virtualMachines/write" where the request body sets identity.type = "None" on a workload that previously had a SystemAssigned identity — supply-chain trigger for falling back to embedded secrets.
AzureActivity creation of Microsoft.KeyVault/vaults/secrets resources within an hour of the identity removal — confirms the secret-store fallback path.
AuditLogs Category = "ApplicationManagement" showing client-credential or password-credential addition to a service principal that maps to a workload also running with managed identity disabled.
Query
AzureActivity
| where OperationNameValue in ("Microsoft.Web/sites/write", "Microsoft.Compute/virtualMachines/write", "Microsoft.App/containerApps/write")
| where ActivityStatusValue == "Success"
| extend identityType = tostring(parse_json(Properties).requestbody)
| where identityType has "\"identity\":{\"type\":\"None\"" or identityType has "removed"
| project TimeGenerated, Caller, ResourceId, OperationNameValue, identityType
| order by TimeGenerated desc
| take 200
Run as a KQL query in Log Analytics. Pair with a Sentinel analytics rule that joins against KeyVault secret-write events from the same ResourceGroup to surface the credential-fallback supply-chain signature.
Alert threshold
Any workload type write that removes a managed identity in production — page on first occurrence; the workload should be re-platformed onto managed identity before redeploy.
Service-principal client-credential addition where the same app already has a federated credential — likely identity downgrade and merits review of the change-management ticket.
Initial response
Restore the managed identity assignment via Bicep/Terraform redeploy of the workload module; capture the resource-graph diff as the rollback ledger.
If a Key Vault secret was created during the exposure window, rotate it immediately and review which principals subsequently read the secret via Key Vault diagnostic-setting logs.
Escalate per general/ir.html — confirm the Azure Policy Audit usage of custom RBAC roles deny-rule for identity-type=None is in deny mode, not just audit.
Restrict end-user consent for third-party OAuth applications to verified publishers claiming only low-risk delegated scopes; require admin consent for everything else, and disable end-user creation of application registrations entirely. OAuth consent-grant abuse is one of the highest-leverage initial-access patterns against Entra ID tenants: an attacker registers a multi-tenant application that requests broad Microsoft Graph scopes, sends a consent URL to a targeted user, and — if consent is granted — receives long-lived access to mail, files, and directory data that survives password resets, MFA enrolment, and most session-revocation actions. The January 2024 Midnight Blizzard intrusion against Microsoft itself exploited exactly this pattern: a legacy non-production test tenant lacked the consent and application restrictions documented here, and the actor used OAuth consent grants to escalate from a password-spray foothold to corporate mailbox access (MSRC — Midnight Blizzard advisory, Jan 2024 (accessed 2026-05)).
Remediation — Azure CLI / Microsoft Graph
# Set tenant-wide consent policy: users may consent only to apps from verified
# publishers, for low-risk delegated permissions only. Everything else
# requires admin consent (admin-consent-workflow generates a request the
# privileged-role-admin reviews in the admin centre).
cat > authz-policy.json <<'JSON'
{
"defaultUserRolePermissions": {
"allowedToCreateApps": false,
"allowedToCreateSecurityGroups": false,
"allowedToReadOtherUsers": true
},
"permissionGrantPolicyIdsAssignedToDefaultUserRole": [
"ManagePermissionGrantsForSelf.microsoft-user-default-low"
]
}
JSON
az rest --method PATCH \
--uri "https://graph.microsoft.com/v1.0/policies/authorizationPolicy" \
--body @authz-policy.json \
--headers "Content-Type=application/json"
# Audit: any existing high-risk consent grants users have already given.
az rest --method GET \
--uri "https://graph.microsoft.com/v1.0/oauth2PermissionGrants" \
--query 'value[?consentType==`Principal`].{user:principalId,app:clientId,scope:scope}' \
--output table
Remediation — Terraform
# Terraform AzureRM provider ~> 3.0
resource "azuread_authorization_policy" "tenant" {
default_user_role_permissions {
allowed_to_create_apps = false
allowed_to_create_security_groups = false
allowed_to_read_other_users = true
}
# Pin the tenant to Microsoft's built-in "low-risk delegated permissions for
# verified publishers" preset; any future change is detected on next plan.
permission_grant_policy_ids_assigned_to_default_user_role = [
"ManagePermissionGrantsForSelf.microsoft-user-default-low"
]
}
AuditLogs Category = "ApplicationManagement" with ActivityDisplayName = "Consent to application" where the consent type is AllPrincipals (admin consent) or where the requested permission set includes high-risk Graph scopes (Mail.ReadWrite, Files.ReadWrite.All, Directory.ReadWrite.All).
AuditLogs ActivityDisplayName = "Add OAuth2PermissionGrant" issued by a non-admin Caller — would indicate the user-consent policy was relaxed.
SigninLogs entries for the newly consented application followed within minutes by a Microsoft Graph access spike — classic illicit-consent exfiltration sequence.
Query
AuditLogs
| where Category == "ApplicationManagement"
| where ActivityDisplayName in ("Consent to application", "Add app role assignment grant to user", "Add OAuth2PermissionGrant")
| extend appName = tostring(TargetResources[0].displayName)
| extend perms = tostring(TargetResources[0].modifiedProperties)
| where perms has "Mail." or perms has "Files." or perms has "Directory." or perms has "Sites.FullControl"
| project TimeGenerated, ActivityDisplayName, appName, InitiatedBy, perms, Result
| order by TimeGenerated desc
| take 200
Run the KQL in Log Analytics. The illicit-consent attack lands as exactly one of these events and is best caught at audit time; persist as a Sentinel analytics rule with severity High and route to identity-security on-call.
Alert threshold
Admin consent to any app outside the documented allow-list — page on first occurrence.
User consent involving any high-risk Graph scope (Mail/Files/Directory/Sites FullControl) — page on first occurrence even if user-consent policy nominally permits it.
Initial response
Revoke the consent via the Entra portal (Enterprise applications → {app} → Permissions → Review permissions → Remove all) or PowerShell Remove-MgServicePrincipalOauth2PermissionGrant; this kills the OAuth refresh token chain.
Enumerate Graph activity issued by the application during the consent window — Microsoft Graph activity logs (preview) or audit via Defender for Cloud Apps connector — and identify which mailboxes / OneDrive folders the app touched.
Escalate per general/ir.html — confirm the tenant user-consent setting is back to Do not allow user consent (or the documented allow-list policy) and that the application is hard-disabled via service-principal disable.
Restrict guest invitations to specific admin-equivalent roles, limit guest user directory permissions to the most restrictive preset, and run quarterly Entra ID Access Reviews over every group containing guests, every privileged role with guest members, and every application where guest users have been granted access. Tenants that accept B2B collaboration accumulate guest accounts continuously; without periodic review, departed contractors and stale partner-organisation accounts retain access indefinitely. This control is DETECTIVE because the Access Review surfaces the unsafe state (guests who should no longer be present) rather than preventing the invitation itself — invitations are a normal business operation. CIS Microsoft Azure Foundations Benchmark v3.0.0 section 1.1.8 codifies the guest-permission baseline; the SCuBA M365 baseline likewise pins the Access Review cadence.
Remediation — Azure CLI / Microsoft Graph
# Restrict guest invite permissions to admin-equivalent roles and pin guest
# directory permissions to the most restrictive preset.
cat > guest-restrictions.json <<'JSON'
{
"allowInvitesFrom": "adminsAndGuestInviters",
"guestUserRoleId": "2af84b1e-32c8-42b7-82bc-daa82404023b"
}
JSON
az rest --method PATCH \
--uri "https://graph.microsoft.com/v1.0/policies/authorizationPolicy" \
--body @guest-restrictions.json \
--headers "Content-Type=application/json"
# Create a quarterly Access Review of all guests in the "external-partners"
# group. Reviewers are the group owners; un-reviewed guests are removed.
cat > access-review.json <<'JSON'
{
"displayName": "Quarterly review — external partner guests",
"descriptionForAdmins": "Auto-remove guests not reviewed in 14 days.",
"scope": {
"@odata.type": "#microsoft.graph.accessReviewQueryScope",
"query": "/groups/<PARTNER_GROUP_ID>/transitiveMembers/microsoft.graph.user/?\$filter=userType eq 'Guest'",
"queryType": "MicrosoftGraph"
},
"reviewers": [{ "query": "/groups/<PARTNER_GROUP_ID>/owners", "queryType": "MicrosoftGraph" }],
"settings": {
"recurrence": {
"pattern": { "type": "absoluteMonthly", "interval": 3 },
"range": { "type": "noEnd", "startDate": "2026-06-01" }
},
"decisionsThatWillMoveToNextStage": ["NotReviewed"],
"defaultDecision": "Deny",
"defaultDecisionEnabled": true,
"instanceDurationInDays": 14
}
}
JSON
az rest --method POST \
--uri "https://graph.microsoft.com/v1.0/identityGovernance/accessReviews/definitions" \
--body @access-review.json \
--headers "Content-Type=application/json"
Remediation — Terraform
# Terraform AzureRM provider ~> 3.0
# Guest-invite restriction lives in authorization policy; Access Reviews are
# not yet first-class in the azuread provider and are managed via az rest in
# a post-apply provisioner (or directly via Graph).
resource "azuread_authorization_policy" "guest_restrictions" {
allow_invites_from = "adminsAndGuestInviters"
# 2af84b1e-32c8-42b7-82bc-daa82404023b = "Guest users have limited access"
guest_user_role_id = "2af84b1e-32c8-42b7-82bc-daa82404023b"
default_user_role_permissions {
allowed_to_create_apps = false
allowed_to_create_security_groups = false
allowed_to_read_other_users = true
}
}
AuditLogs Category = "UserManagement" with ActivityDisplayName = "Invite external user" targeting a domain outside the documented partner allow-list — or "Add user" creating a guest with userType = Guest from an unexpected source domain.
AuditLogs role assignment to a guest principal that grants Directory roles or privileged Azure RBAC scopes — guests should remain consumer-scoped.
SigninLogs sign-in by a guest principal to Azure Resource Manager from a previously-unseen geography — indicates the guest account has been weaponised.
Query
AuditLogs
| where Category == "UserManagement"
| where ActivityDisplayName in ("Invite external user", "Add user")
| extend invitedDomain = tostring(split(tostring(TargetResources[0].userPrincipalName), "#EXT#")[0])
| extend invitedDomain = tostring(split(invitedDomain, "_")[-1])
| where invitedDomain !in ("partner-a.example", "partner-b.example")
| project TimeGenerated, ActivityDisplayName, invitedDomain, InitiatedBy, Result
| order by TimeGenerated desc
| take 200
Run as a KQL query in Log Analytics; maintain the partner allow-list as a Sentinel watchlist so the query stays declarative. Promote to a Sentinel analytics rule with severity Medium and an automation playbook that auto-disables the invited guest pending approval.
Alert threshold
Any guest invitation from a domain outside the partner allow-list — page on first occurrence; the guest should be disabled and the inviter reviewed.
Any privileged-role assignment (Owner / Contributor / User Access Administrator) on a guest principal — page immediately; guests have no business in those scopes.
Initial response
Disable the offending guest via Update-MgUser -UserId {id} -AccountEnabled:$false; capture the AuditLogs invitation record as the ledger of who issued the access.
Walk recent activity for the guest principal in AzureActivity + SigninLogs — guests with any privileged write should be treated as full incident-grade.
Escalate per general/ir.html — confirm the External Collaboration Settings still enforce Member users only for invitations, and that the cross-tenant access policy for the source tenant remains in deny mode.