1

Hardening Entra ID with Terraform

A practical implementation guide covering identity baseline, privileged access, conditional access, and monitoring – fully managed as infrastructure as code.…

A practical implementation guide covering identity baseline, privileged access, conditional access, and monitoring – fully managed as infrastructure as code.


Entra ID is the identity backbone of most Microsoft 365 and Azure environments, yet it’s one of the last places organisations apply Infrastructure as Code discipline. Firewall rules get reviewed in PRs. Storage Account settings are version-controlled. Entra ID? Someone clicks through the Portal, maybe writes it down, and six months later nobody is sure what the current state actually is.

This post documents a Terraform configuration I built to change that. It covers the full stack: identity defaults, privileged role protection, conditional access policies, a hardened break-glass account, and a monitoring setup that alerts within five minutes of that account being used.

If you want the reference material the configuration is based on, Sean Metcalf’s “Improve Entra ID Security More Quickly” blog post is a well-structured starting point.


Why Not Bicep?

versions.tf

The Microsoft Graph Bicep Extension went GA in July 2025. After investigation, the current v1.0 release covers seven resource types – applications, service principals, groups, federated identity credentials, app role assignments, OAuth2 permission grants, and users (read-only). That’s useful for application lifecycle management, not security hardening.

Authorization Policy, Conditional Access Policies, PIM assignments, and Authentication Strength Policies are all missing. The Terraform azuread provider covers all of these, so that became the tool. Three providers in total are needed:

azuread = "~> 3.8.0"   # 3.8.0+ required for device code flow CA condition
azurerm = "~> 4.0"     # for Log Analytics Workspace + alerting
null    = "~> 3.2.0"   # for the Authorization Policy workaround

Authentication – Service Principal Required

providers.tf

The configuration must run as a Service Principal with application permissions. Several resources – Authentication Strength Policies, Conditional Access Policies, and the Authorization Policy endpoint – require scopes that Microsoft does not pre-authorize for the Azure CLI’s own first-party app ID. Running az login and deploying will get you 403s on roughly half the security-relevant resources.

The SP needs these Microsoft Graph application permissions:

PermissionRequired for
Policy.ReadWrite.AuthorizationAuthorization Policy endpoint
Policy.ReadWrite.ConditionalAccessCA policies + Auth Strength
Policy.Read.AllReading existing policies
RoleManagement.ReadWrite.DirectoryPIM assignments
Directory.ReadWrite.AllGroups + authorization policy
Group.ReadWrite.AllRole Assignable Groups
Application.ReadWrite.AllApp registrations
AppRoleAssignment.ReadWrite.AllApp role grants

Plus two Azure RBAC assignments: Contributor on the subscription for the monitoring resources, and Contributor at /providers/Microsoft.aadiam for the Diagnostic Setting. The second one is separate from subscription scope and requires User Access Administrator at root to assign:

az role assignment create \
  --assignee-principal-type ServicePrincipal \
  --assignee-object-id "<sp-object-id>" \
  --scope "/providers/Microsoft.aadiam" \
  --role "Contributor"

Section 1: Authorization Policy

authorization_policy.tf

The most impactful tenant-wide settings live at a single Graph API endpoint: PATCH /v1.0/policies/authorizationPolicy. This controls whether users can register applications, create tenants, invite guests, and consent to app permissions – six distinct security settings in one call.

There is no azuread_authorization_policy resource. The workaround is a null_resource with a local-exec provisioner that calls the Graph API directly. Two things I learned the hard way making this work:

az rest uses the wrong token scope. After SP login, az rest fetches a token for management.azure.com by default, not graph.microsoft.com. The fix is to request a Graph-scoped token explicitly and use curl:

TOKEN=$(az account get-access-token \
  --resource https://graph.microsoft.com \
  --query accessToken \
  --output tsv) && \
curl -sf -X PATCH \
  "https://graph.microsoft.com/v1.0/policies/authorizationPolicy" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '${jsonencode(local.authorization_policy_body)}'

--allow-no-subscriptions is also needed on az login because the SP has no Azure subscription access – only Entra ID permissions.

The policy body is defined as a local so the hash-based trigger and the API call always stay in sync:

triggers = {
  policy_hash = sha256(jsonencode(local.authorization_policy_body))
}

This means the local-exec re-runs automatically whenever any policy value changes – no manual version bumping needed.


Section 2: Role Assignable Groups

role_assignable_groups.tf

Role Assignable Groups (RAGs) close a specific lateral movement path: when a normal group is assigned to an Entra ID role, any Group Owner can modify its membership and effectively assume the role. The Microsoft docs on what RAGs actually change are worth reading carefully:

By default, Privileged Role Administrators can manage the membership of a role-assignable group, but you can delegate the management of role-assignable groups by adding group owners.

RAG owners can manage membership. The security benefit is that you control explicitly who those owners are. For a regular group, any user set as owner can change membership. For a RAG, you make a deliberate choice.

The configuration creates five RAGs for the Tier 0 roles: Global Administrator, Privileged Role Administrator, Privileged Authentication Administrator, Hybrid Identity Administrator, and Partner Tier2 Support. The deploying Service Principal is set as sole owner for all five:

resource "azuread_group" "rag_global_admin" {
  display_name       = "RAG-Tier0-GlobalAdministrators"
  security_enabled   = true
  mail_enabled       = false
  assignable_to_role = true  # immutable after creation
  members            = var.tier0_admin_object_ids
  owners             = [data.azuread_client_config.current.object_id]
}

Ownership is fully Terraform-managed and version-controlled. Any change has to go through the IaC process. The SP never acts interactively, which is exactly what you want from an owner of a Tier 0 group.

One provider limitation: owners = [] causes a validation error – at least one owner is required. This forces an explicit ownership decision, which in retrospect is the right forcing function.


Section 3: PIM Eligible Assignments

pim_assignments.tf

azuread_directory_role_eligibility_schedule_request works cleanly with for_each over the list of admin Object IDs:

resource "azuread_directory_role_eligibility_schedule_request" "global_admin" {
  for_each = toset(var.tier0_admin_object_ids)

  role_definition_id = local.role_global_administrator
  principal_id       = each.value
  directory_scope_id = "/"
  justification      = "Tier 0 PIM eligible assignment – managed via Terraform IaC"
}

Requires Entra ID P2 licensing.


Section 4: Application Permission Control

applications.tf

Certain Microsoft Graph application permissions grant near-unlimited control over Entra ID. Any application holding them is effectively Tier 0:

  • Directory.ReadWrite.All – equivalent to Global Administrator
  • AppRoleAssignment.ReadWrite.All – can grant itself or others any permission
  • RoleManagement.ReadWrite.Directory – can assign any directory role
  • Application.ReadWrite.All – can impersonate any application

Terraform can enforce the correct pattern for new app registrations. Auditing existing grants across a tenant requires PowerShell:

$tier0 = @(
  "19dbc75e-c2e2-444c-a770-ec69d8559fc7",
  "06b708a9-e830-4db3-a914-8e69da51d44f",
  "9e3f62cf-ca93-4e58-908e-1e76e63a0b39",
  "1bfefb4e-e0b5-418b-a88f-73c46d2cc8e9"
)
Get-MgServicePrincipal -All | ForEach-Object {
  $sp = $_
  Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $sp.Id |
    Where-Object { $_.AppRoleId -in $tier0 } |
    Select-Object @{N="App";E={$sp.DisplayName}}, AppRoleId
}

Section 5: Authentication Strength Policies

authentication_strength.tf

Authentication Strength policies define which authentication methods satisfy an MFA requirement. Two policies are deployed.

For Tier 0 admins, only phishing-resistant methods are accepted:

allowed_combinations = [
  "fido2",
  "windowsHelloForBusiness",
]

Microsoft Authenticator push is intentionally excluded. It’s vulnerable to MFA fatigue attacks and real-time phishing proxies – exactly the threat vectors targeting privileged accounts.

For all other users, a broader standard MFA policy allows Authenticator push and FIDO2. SMS and voice are excluded from both policies.


Section 6: Conditional Access Policies

conditional_access_policies.tf

Six policies are deployed, all controlled by a single variable:

variable "ca_policy_state" {
  default = "enabledForReportingButNotEnforced"
}

Deploy in report-only mode, review Sign-in logs for two to four weeks, then switch to "enabled". The state applies to all six policies at once.

All policies automatically exclude a combined list via a single local:

locals {
  all_ca_excluded_ids = distinct(concat(
    [azuread_user.emergency_access.object_id],
    var.ca_excluded_user_ids,
  ))
}

Add your own Object ID to ca_excluded_user_ids during initial testing and remove it before enforcing.

The six policies:

PolicyTargetControl
CA-001Tier 0 role holdersPhishing-resistant MFA (FIDO2/WHfB only)
CA-002All 16 privileged rolesStandard MFA
CA-003All usersBlock legacy auth (SMTP, POP3, IMAP)
CA-004All usersBlock device code flow
CA-005Guest usersStandard MFA
CA-006All usersStandard MFA on device Entra join

Two provider issues worth documenting:

Hybrid Identity Administrator cannot appear in included_roles. The Graph API returns a 400 with “non-built-in role ids” despite it appearing in Microsoft’s own privileged role list. It’s excluded with a comment explaining why.

The device code flow condition (authentication_flow_transfer_methods) requires provider >= 3.8.0. Earlier versions reject it regardless of syntax – hence the specific version pin.


Section 7: Emergency Access Account

emergency_access.tf

Three things matter here beyond just creating the user:

Use the .onmicrosoft.com domain. If your primary domain is federated and federation breaks, only accounts on the .onmicrosoft.com domain can still authenticate directly.

Permanent active Global Administrator – not PIM eligible. The break-glass account exists for scenarios where PIM itself is unavailable. A PIM eligible assignment is useless in exactly the emergency you built this account for:

resource "azuread_directory_role_assignment" "emergency_access_ga" {
  role_id             = local.role_global_administrator
  principal_object_id = azuread_user.emergency_access.object_id
}

This is the only account in the configuration with a permanent active role assignment.

Register a FIDO2 key after deployment. This is the one step that cannot be automated. Entra Portal → Users → Emergency Access – Break Glass → Authentication methods → Add → Passkey (FIDO2). Store the key physically secure and separate from the password.


Section 8: Monitoring and Alerting

diagnostic_settings.tf

The configuration deploys a complete monitoring stack: a Resource Group, Log Analytics Workspace with 90-day retention, a Diagnostic Setting streaming all Entra ID log categories, an Action Group for email delivery, and a Scheduled Query Rule that fires within five minutes of any successful break-glass sign-in.

SigninLogs
| where UserPrincipalName =~ "emergency@yourdomain.onmicrosoft.com"
| where ResultType == 0
| project TimeGenerated, UserPrincipalName, IPAddress, Location, AppDisplayName, DeviceDetail

If this alert fires under normal operations, something is wrong. The projected fields are your starting point for investigation.

One prerequisite that’s easy to miss: azurerm_monitor_aad_diagnostic_setting requires Contributor at /providers/Microsoft.aadiam – separate from subscription Contributor. See the Authentication section above.

After deployment, expect 15–30 minutes before logs appear in the workspace. The Entra Portal shows logs from a different internal data path, which is why they appear there immediately.


Deployment Order

The CA policies reference the emergency access account Object ID as an exclusion. That account must exist before the CA policies can be created.

# 1. Init
terraform init

# 2. Emergency access account first
terraform apply \
  -target=azuread_user.emergency_access \
  -target=azuread_directory_role_assignment.emergency_access_ga

# 3. Configure tfvars with the output Object ID, then full deploy
terraform apply

# 4. After validating CA policies in report-only mode:
# update ca_policy_state = "enabled" and remove your test exclusion
terraform apply

Full deployment guide including SP setup and RBAC assignments is in the repository README.


Full Code

github.com/simon-vedder/terraform-azure/initial-entra-securesetup

PRs are welcome. The azuread provider is moving quickly and some workarounds documented here – particularly the Authorization Policy null_resource – may become unnecessary as new resource types are added.

Simon

Cloud Engineer focused on Azure, Terraform & PowerShell. Passionate about automation, efficient solutions, and sharing real-world cloud projects and insights.⸻Let me know if you’d like to make it more casual, technical, or personalized.

Leave a Reply

Your email address will not be published. Required fields are marked *