Azure IAM Hardening

Overview

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).

azure-iam-01-global-admin-count ! CRITICAL PREVENTIVE

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.51.1.11.11.1 AC-6(7); AC-2A.5.15; A.5.16n/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

  1. Verify the assignment against the documented PIM justification ticket and the named Caller; if no ticket exists, treat as confirmed compromise.
  2. 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.
  3. 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.

References

Equivalent on: AWS · GCP · OCI

azure-iam-02-emergency-access ! HIGH PREVENTIVE

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"

Remediation — Terraform

# Terraform AzureRM provider ~> 3.0
# (Account objects in azuread provider; resource-plane Key Vault for the
# offline credential escrow uses AzureRM.)
resource "azuread_user" "breakglass" {
  for_each            = toset(["breakglass01", "breakglass02"])
  user_principal_name = "${each.key}@${var.tenant_onmicrosoft_domain}"
  display_name        = "Break-Glass ${each.key}"
  password            = random_password.bg[each.key].result
  # Cloud-only: never synchronized from on-prem AD.
  account_enabled     = true
}

resource "azuread_group" "emergency_access_exclusions" {
  display_name     = "emergency-access-exclusions"
  security_enabled = true
}

resource "azuread_group_member" "bg_in_exclusions" {
  for_each         = azuread_user.breakglass
  group_object_id  = azuread_group.emergency_access_exclusions.object_id
  member_object_id = each.value.object_id
}

Remediation — Bicep

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.41.1.3best-practicesbest-practices CP-2; AC-2A.5.29; A.5.16n/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

  1. 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.
  2. 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.
  3. 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.

References

Equivalent on: AWS · GCP · OCI

azure-iam-03-pim ! CRITICAL PREVENTIVE

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.211.1.4best-practicesbest-practices AC-6(1); AC-6(2); AC-2(7)A.5.15n/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

  1. Reverse the permanent assignment via the Entra portal Role Settings blade or Update-MgRoleManagementDirectoryRoleAssignment; re-issue as PIM-eligible with the documented approval flow.
  2. 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.
  3. 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.

References

Equivalent on: AWS · GCP · OCI

azure-iam-04-conditional-access-mfa ! CRITICAL PREVENTIVE

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"]
  }
}

Remediation — Bicep

targetScope = 'tenant'

@description('CA policy: require MFA for all users on all cloud apps; excludes break-glass.')
param breakGlassExclusionGroupId string

resource caRequireMfa 'Microsoft.Graph/identity/conditionalAccess/policies@2023-09-01' = {
  name: 'require-mfa-all-users'
  properties: {
    state: 'enabled'
    conditions: {
      users: {
        includeUsers: ['All']
        excludeGroups: [breakGlassExclusionGroupId]
      }
      applications: {
        includeApplications: ['All']
      }
    }
    grantControls: {
      operator: 'AND'
      builtInControls: ['mfa']
    }
  }
}

Remediation — Pulumi (TypeScript)

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.101.1.21.21.7 IA-2(1)A.5.17; A.8.5n/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

  1. 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.
  2. 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.
  3. 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.

References

Equivalent on: AWS · GCP · OCI

azure-iam-05-block-legacy-auth ! HIGH PREVENTIVE

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}'

Remediation — Terraform

# Terraform AzureRM provider ~> 3.0
resource "azuread_conditional_access_policy" "block_legacy_auth" {
  display_name = "CA002 — Block legacy authentication"
  state        = "enabled"

  conditions {
    # 'exchangeActiveSync' covers legacy EAS variants; 'other' catches POP,
    # IMAP, SMTP AUTH, MAPI, EWS, autodiscover-basic-auth, and the catch-all
    # rpc/over-http stacks.
    client_app_types = ["exchangeActiveSync", "other"]

    applications {
      included_applications = ["All"]
    }

    users {
      included_users  = ["All"]
      excluded_groups = [azuread_group.emergency_access_exclusions.object_id]
    }
  }

  grant_controls {
    operator          = "OR"
    built_in_controls = ["block"]
  }
}

Remediation — Bicep

targetScope = 'tenant'

resource blockLegacy 'Microsoft.Graph/identity/conditionalAccess/policies@2023-09-01' = {
  name: 'block-legacy-authentication'
  properties: {
    state: 'enabled'
    conditions: {
      users: { includeUsers: ['All'] }
      applications: { includeApplications: ['All'] }
      clientAppTypes: ['exchangeActiveSync', 'other']
    }
    grantControls: {
      operator: 'OR'
      builtInControls: ['block']
    }
  }
}

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
best-practices1.1.5best-practicesbest-practices IA-2(1); IA-8A.5.17; A.8.5n/a

Log signals

  • 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

  1. 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.
  2. Walk the user's mailbox-rules and forwarding configuration via Exchange Online PowerShell — legacy-auth compromise frequently lands rule-based exfiltration of inbound mail.
  3. 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.

References

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 ! HIGH PREVENTIVE

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)

Remediation — Terraform

# Terraform AzureRM provider ~> 3.0
resource "azurerm_user_assigned_identity" "app_reader" {
  name                = "mi-prod-app-reader"
  resource_group_name = azurerm_resource_group.shared.name
  location            = azurerm_resource_group.shared.location
}

resource "azurerm_role_assignment" "kv_reader" {
  scope                = azurerm_key_vault.prod_app.id
  role_definition_name = "Key Vault Secrets User"
  principal_id         = azurerm_user_assigned_identity.app_reader.principal_id
}

resource "azurerm_linux_web_app" "prod" {
  name                = "app-prod-web"
  resource_group_name = azurerm_resource_group.shared.name
  location            = azurerm_resource_group.shared.location
  service_plan_id     = azurerm_service_plan.prod.id

  identity {
    type         = "UserAssigned"
    identity_ids = [azurerm_user_assigned_identity.app_reader.id]
  }

  site_config {}
}

Remediation — Bicep

targetScope = 'resourceGroup'

@description('User-assigned managed identity name.')
param identityName string

@description('Resource group location.')
param location string = resourceGroup().location

resource uami 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = {
  name: identityName
  location: location
}

output clientId string = uami.properties.clientId
output principalId string = uami.properties.principalId

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
best-practices1.1.6best-practicesbest-practices IA-5(7); AC-6A.5.16CLD.6.3.1

Log signals

  • 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

  1. Restore the managed identity assignment via Bicep/Terraform redeploy of the workload module; capture the resource-graph diff as the rollback ledger.
  2. 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.
  3. 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.

References

Equivalent on: AWS · GCP · OCI

azure-iam-08-guest-restrictions ! MEDIUM DETECTIVE

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
  }
}

Remediation — Bicep

targetScope = 'tenant'

// Restrict guest user permissions to "restricted user" level (most restrictive).
resource guestRestrictions 'Microsoft.Graph/policies/authorizationPolicy@2023-09-01' = {
  name: 'authorizationPolicy'
  properties: {
    guestUserRoleId: '2af84b1e-32c8-42b7-82bc-daa82404023b' // Restricted Guest
    allowInvitesFrom: 'adminsAndGuestInviters'
  }
}

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.12; 1.131.1.8best-practicesbest-practices AC-2(3); AC-2(13)A.5.16; A.5.18n/a

Log signals

  • 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

  1. Disable the offending guest via Update-MgUser -UserId {id} -AccountEnabled:$false; capture the AuditLogs invitation record as the ledger of who issued the access.
  2. Walk recent activity for the guest principal in AzureActivity + SigninLogs — guests with any privileged write should be treated as full incident-grade.
  3. 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.

References

Equivalent on: AWS · GCP · OCI

Sources