Managing Conditional Access Policies with Terraform

Conditional Access policies are too critical to manage by hand. Learn how to define, version, and safely deploy them with Terraform and the hashicorp/azuread provider.

Managing Conditional Access Policies with Terraform

Conditional Access policies are one of the most important security controls in Microsoft Entra ID, but in many environments they are still managed directly in the portal. That works well enough at the beginning. Open the browser, click through the menus, make the change, and move on. For smaller tenants or one-time changes, this feels natural.

The problem starts when the environment grows. Administrators make changes during troubleshooting sessions, temporary exclusions stay in place longer than planned, documentation falls behind, and nobody is entirely sure why a specific policy looks the way it does anymore. Slowly, configuration drift appears. The built-in audit trail is useful, but it is limited unless you export logs to Log Analytics, storage, Event Hubs, or a SIEM.

That is where Terraform becomes useful. Instead of relying on manual portal changes, we can define Conditional Access policies as code. The Terraform configuration becomes the desired state that can be versioned, reviewed through pull requests, tested before applying, and reproduced across tenants. Terraform state maps the configuration to the real resources in Microsoft Entra ID. If someone changes a policy manually in the portal, Terraform can detect that drift during the next refresh and plan. The environment is brought back to the approved configuration when the Terraform state is applied.

In this post, we will manage Microsoft Entra ID Conditional Access policies with Terraform using the hashicorp/azuread provider. We will build the supporting pieces first: named locations, persona groups, exclusion groups, and emergency access handling. Then we will use them to define Conditional Access policies in a controlled, repeatable way.

The policy baseline used in this article is heavily inspired by Joey Verlinden's Conditional Access baseline. Please, have a look at his repository: https://github.com/j0eyv/ConditionalAccessBaseline.

Provider Setup and Permissions

I will assume that Terraform (Terraform install docs) and Azure CLI (Azure CLI install docs) are already installed. Once that is set up, Terraform can use the Azure CLI context or a non-interactive identity to manage Microsoft Entra resources.

Before we start with the actual configuration, we need to decide how the hashicorp/azuread provider will authenticate to Microsoft Entra ID. The provider documentation already explains the authentication options well, so I will not repeat every detail here. Instead, we will use a pattern that keeps credentials out of the Terraform provider block and passes authentication details through environment variables.

For local testing and prototyping, Azure CLI authentication is usually the most convenient option. For production, automation, and pipelines, use a non-interactive identity instead: a service principal with a client certificate, a service principal with a client secret, or managed identity when Terraform runs from an Azure resource that supports it.

If you use Azure CLI authentication, sign in first and make sure the target tenant is selected:

az login --allow-no-subscriptions

For service principal authentication with a client secret, set these values before running Terraform:

export ARM_CLIENT_ID="00000000-0000-0000-0000-000000000000"
export ARM_TENANT_ID="10000000-2000-3000-4000-500000000000"
export ARM_CLIENT_SECRET="change-me"

For service principal authentication with a client certificate, use the certificate settings instead:

export ARM_CLIENT_ID="00000000-0000-0000-0000-000000000000"
export ARM_TENANT_ID="10000000-2000-3000-4000-500000000000"
export ARM_CLIENT_CERTIFICATE_PATH="/path/to/client-certificate.pfx"
export ARM_CLIENT_CERTIFICATE_PASSWORD="change-me"

For managed identity, enable MSI authentication and specify the tenant ID:

export ARM_USE_MSI=true
export ARM_TENANT_ID="10000000-2000-3000-4000-500000000000"

If you are using PowerShell, environment variables are not set with export. Use this notation instead: $env:ARM_CLIENT_ID="00000000-0000-0000-0000-000000000000".

Avoid putting secrets, certificate passwords, or tenant-specific credentials directly in the provider block. It is too easy for those values to end up in source control. Environment variables, pipeline secrets, workload identity federation, or managed identity are safer patterns for real environments.

API Permissions

When Terraform runs with a service principal, that service principal needs Microsoft Graph application permissions for the objects we want it to manage. In this setup, we assign the required API permissions directly to the application registration and grant admin consent.

For the resources we create in this article, the service principal needs these application permissions:

Permission

Purpose

Policy.ReadWrite.ConditionalAccess

Create and update Conditional Access policies and named locations

Policy.Read.All

Read Conditional Access policy configuration

Group.ReadWrite.All

Create and manage groups used for exclusions and assignments

User.ReadWrite.All

Manage user objects, including emergency access accounts

RoleManagement.ReadWrite.Directory

Required when using assignable_to_role = true on a group

The last permission deserves a short explanation. A role-assignable group receives additional protection in Microsoft Entra ID. These groups must use assigned membership, have additional management restrictions, and require stronger permissions to manage. This is useful for sensitive groups, such as a Conditional Access exclusion group for emergency access accounts, because we do not want lower-tier administrators to add themselves or others to that group and silently bypass the policies we are trying to enforce.

After adding these application permissions to the app registration, grant admin consent before running Terraform. Without admin consent, authentication may still succeed, but the provider will fail when it tries to read or modify protected Microsoft Graph resources.

Local Project Setup

For the article, we will keep the project small and local. Remote state can be introduced later when this moves into team or CI/CD usage.

Start with this layout:

terraform_cap_framework/
├── .env
├── .gitignore
├── main.tf
├── providers.tf
└── versions.tf

The .env file holds authentication environment variables such as ARM_CLIENT_ID and ARM_CLIENT_SECRET. Source it before running Terraform:

source .env

Terraform variable values go in a terraform.tfvars file. The repository includes a terraform.tfvars.example with example starting values. Copy it and adjust for your tenant:

cp terraform.tfvars.example terraform.tfvars

Create .gitignore before adding the rest of the configuration so local credentials, state, and Terraform working files do not end up in source control:

.env
.terraform/
*.tfstate
*.tfstate.*
crash.log
crash.*.log
*.tfvars
*.tfvars.json

In versions.tf, pin Terraform and the provider:

terraform {
  required_version = ">= 1.15.0"

  required_providers {
    azuread = {
      source  = "hashicorp/azuread"
      version = "~> 3.8"
    }
  }
}

In providers.tf, keep the provider block minimal because authentication comes from the environment:

provider "azuread" {}

Initialize the project:

terraform init

Emergency Access

Before creating Conditional Access policies, we need a plan for emergency access. These policies control who can access resources in the tenant, based on signals like user, location, device, risk, and authentication strength. A mistake in this layer can lock you out of the tenant.

The safest pattern is to maintain dedicated emergency access accounts that are highly protected, monitored, and excluded from Conditional Access policies. These accounts should not be tied to a normal person and should not be used for day-to-day administration. They exist for testing and real emergencies.

This project creates two emergency access users and one emergency access exclusion group:

variable "breakglass_upn_1" {
  description = "User principal name for the first emergency access account."
  type        = string
  default     = "btg.1@example.com"
}

variable "breakglass_upn_2" {
  description = "User principal name for the second emergency access account."
  type        = string
  default     = "btg.2@example.com"
}

variable "breakglass_group_name" {
  description = "Display name for the emergency access exclusion group."
  type        = string
  default     = "CA-Emergency-Access-Exclusion"
}

The users are defined with placeholder passwords:

resource "azuread_user" "breakglass_1" {
  user_principal_name         = var.breakglass_upn_1
  display_name                = "Emergency Access 1"
  account_enabled             = true
  disable_password_expiration = true

  # Set a strong initial password. Rotate this immediately after first use
  # and store it securely in a vault, not in source control.
  password = "ChangeMe!InitialP@sswd1"
}

resource "azuread_user" "breakglass_2" {
  user_principal_name         = var.breakglass_upn_2
  display_name                = "Emergency Access 2"
  account_enabled             = true
  disable_password_expiration = true

  password = "ChangeMe!InitialP@sswd2"
}

The password field is required by the azuread_user resource when creating a new account. Do not commit real passwords to source control. Use placeholders for initial creation, rotate the passwords immediately after first apply, and store operational credentials in a vault.

The exclusion group is role-assignable and owned by the Terraform principal:

data "azuread_client_config" "current" {}

resource "azuread_group" "breakglass" {
  display_name       = var.breakglass_group_name
  description        = "Emergency access accounts excluded from all Conditional Access policies. Handle with care."
  security_enabled   = true
  assignable_to_role = true

  owners = [data.azuread_client_config.current.object_id]

  members = [
    azuread_user.breakglass_1.object_id,
    azuread_user.breakglass_2.object_id,
  ]
}

Every active Conditional Access policy should exclude this group. The final implementation also creates a separate exclusion group for each policy, which gives you a controlled place to add temporary or policy-specific exclusions without editing the policy logic.

Named Locations

Named locations are admin-defined network or geographic boundaries used as signals in Conditional Access policies. There are two common types:

  1. IP ranges, such as office or VPN egress ranges.
  2. Countries or regions, based on IP geolocation or, if configured, GPS signals from Microsoft Authenticator.

In this example, the organization operates in the European Union and wants to block sign-ins from outside that region. We create one country-based named location:

resource "azuread_named_location" "eu" {
  display_name = "European Union"

  country {
    countries_and_regions = [
      "AT", # Austria
      "BE", # Belgium
      "BG", # Bulgaria
      "CY", # Cyprus
      "CZ", # Czech Republic
      "DE", # Germany
      "DK", # Denmark
      "EE", # Estonia
      "ES", # Spain
      "FI", # Finland
      "FR", # France
      "GR", # Greece
      "HR", # Croatia
      "HU", # Hungary
      "IE", # Ireland
      "IT", # Italy
      "LT", # Lithuania
      "LU", # Luxembourg
      "LV", # Latvia
      "MT", # Malta
      "NL", # Netherlands
      "PL", # Poland
      "PT", # Portugal
      "RO", # Romania
      "SE", # Sweden
      "SI", # Slovenia
      "SK", # Slovakia
    ]

    include_unknown_countries_and_regions = false
  }
}

This location is later used by policies that block access from outside the expected region.

Design Principles

Before adding a large set of policies, it helps to make the design explicit.

The baseline follows these principles:

  • Keep policies in source control, one policy per file.
  • Keep every active policy in report-only mode until it has been tested and approved.
  • Exclude the emergency access group from every active policy.
  • Create one exclusion group per policy, even if it starts empty.
  • Use Terraform references instead of hard-coded object IDs wherever possible.
  • Assign policies to persona groups rather than repeating group object IDs in policy files.

I am keeping unsupported or intentionally inactive policies as commented placeholders. I am doing this to honor Joey's baseline, but you shouldn't. Try to keep the amount of comments to a minimum.

The per-policy exclusion group pattern is especially useful. If a policy unexpectedly affects a user, device, or process, you can add the affected object to the policy-specific exclusion group. The policy logic remains stable, and the exception is visible through group membership. Even better, you can connect the process of requesting an exception to your PIM and have traceable, time-bound exceptions (for example, when someone travels and needs to work from a country outside the whitelist).

Persona Groups

The framework uses persona groups to scope policies. The framework uses 5 types of policies baed on 4 persona types. The first type is global, so it is not scoped to a specific subset of users. The rest are the groups: internals, admins, guests and service accounts. For all except for guests, I would suggest to use groups. This keeps policy files readable and avoids embedding tenant-specific object IDs in every policy and also grants you some flexibility (for example, when defining what accounts are administrative and not having to rely solely on the directory roles).

The groups for the personas are defined as follows:

Persona

Terraform resource

Membership type

Example default rule or membership

Admins

azuread_group.admin_persona

Dynamic

UPN starts with adm. and ends with .onmicrosoft.com

Internals

azuread_group.internals_persona

Dynamic

user.companyName -eq "Example Corp"

Service Accounts

azuread_group.service_accounts_persona

Assigned

Empty set by default

The variables live in variables.tf:

variable "admin_persona_group_name" {
  description = "Display name for the dynamic admin persona group."
  type        = string
  default     = "CA-Persona-Admins"
}

variable "admin_persona_dynamic_membership_rule" {
  description = "Dynamic membership rule for the admin persona group."
  type        = string
  default     = "(user.userPrincipalName -startsWith \"adm.\") -and (user.userPrincipalName -endsWith \".onmicrosoft.com\")"
}

variable "internals_persona_group_name" {
  description = "Display name for the dynamic internals persona group."
  type        = string
  default     = "CA-Persona-Internals"
}

variable "internals_persona_dynamic_membership_rule" {
  description = "Dynamic membership rule for the internals persona group."
  type        = string
  default     = "(user.companyName -eq \"Example Corp\")"
}

variable "service_accounts_persona_group_name" {
  description = "Display name for the assigned service accounts persona group."
  type        = string
  default     = "CA-Persona-ServiceAccounts"
}

variable "service_accounts_persona_member_object_ids" {
  description = "Object IDs for members of the service accounts persona group."
  type        = set(string)
  default     = []
}

The corresponding groups live in persona_groups.tf:

resource "azuread_group" "admin_persona" {
  display_name     = var.admin_persona_group_name
  description      = "Dynamic group for accounts in the admin persona."
  security_enabled = true
  types            = ["DynamicMembership"]

  dynamic_membership {
    enabled = true
    rule    = var.admin_persona_dynamic_membership_rule
  }
}

resource "azuread_group" "internals_persona" {
  display_name     = var.internals_persona_group_name
  description      = "Dynamic group for accounts in the internals persona."
  security_enabled = true
  types            = ["DynamicMembership"]

  dynamic_membership {
    enabled = true
    rule    = var.internals_persona_dynamic_membership_rule
  }
}

resource "azuread_group" "service_accounts_persona" {
  display_name     = var.service_accounts_persona_group_name
  description      = "Assigned group for accounts in the service accounts persona."
  security_enabled = true

  members = var.service_accounts_persona_member_object_ids
}

Dynamic membership is useful for broad personas that can be described through user attributes. Assigned membership is better for service accounts, because those accounts usually need deliberate ownership and review.

Conditional Access Policy Anatomy

Every azuread_conditional_access_policy has the same broad shape:

resource "azuread_conditional_access_policy" "example" {
  display_name = "CA100-AttackSurfaceReduction-AnyApp-AnyPlatform-Block"
  state        = "enabledForReportingButNotEnforced"

  conditions {
    client_app_types = ["all"]

    applications {
      included_applications = ["All"]
    }

    users {
      included_groups = [var.internals_persona_group_object_id]
      excluded_groups = [
        var.breakglass_group_object_id,
        azuread_group.example_exclusion.object_id,
      ]
    }
  }

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

The conditions block decides when the policy applies. Common nested blocks and fields include:

  • applications: included and excluded cloud apps.
  • users: users, groups, directory roles, guests, external users, and exclusions.
  • locations: included or excluded named locations.
  • platforms: Windows, macOS, iOS, Android, Linux, or unknown platforms.
  • client_app_types: browser, mobile and desktop clients, legacy clients, or all.
  • sign_in_risk_levels: sign-in risk from Microsoft Entra ID Protection.
  • user_risk_levels: user risk from Microsoft Entra ID Protection.
  • authentication_flow_transfer_methods: device code flow or authentication transfer.

The grant_controls block decides what happens when the conditions match. It can block access, require a compliant device, require a hybrid joined device, require a password change, or require a specific authentication strength.

For MFA, this baseline uses Microsoft built-in authentication strengths where appropriate:

authentication_strength_policy_id = "/policies/authenticationStrengthPolicies/00000000-0000-0000-0000-000000000002"

For phishing-resistant MFA, the admin policy uses:

authentication_strength_policy_id = "/policies/authenticationStrengthPolicies/00000000-0000-0000-0000-000000000004"

Do not combine built_in_controls = ["mfa"] and authentication_strength_policy_id in the same policy. Pick one model per policy.

The session_controls block is used for behavior such as sign-in frequency and persistent browser mode:

session_controls {
  sign_in_frequency                     = 10
  sign_in_frequency_period              = "hours"
  sign_in_frequency_authentication_type = "primaryAndSecondaryAuthentication"
  sign_in_frequency_interval            = "timeBased"
}

Framework Structure

Once the supporting resources and policies are in place, the repository looks like this:

terraform_cap_framework/
├── breakglass.tf
├── conversion_warnings.md
├── main.tf
├── named_locations.tf
├── persona_groups.tf
├── providers.tf
├── terraform.tfvars.example
├── variables.tf
├── versions.tf
└── Policies/
    ├── CA000-Global-IdentityProtection-AnyApp-AnyPlatform-MFA.tf
    ├── CA001-Global-AttackSurfaceReduction-AnyApp-AnyPlatform-BLOCK-CountryWhitelist.tf
    ├── CA100-Admins-IdentityProtection-AnyApp-AnyPlatform-PhishingResistantMFA.tf
    ├── CA200-Internals-IdentityProtection-AnyApp-AnyPlatform-MFA.tf
    ├── CA300-ServiceAccounts-IdentityProtection-AnyApp-AnyPlatform-MFA.tf
    └── CA400-GuestUsers-IdentityProtection-AnyApp-AnyPlatform-MFA.tf

The Policies/ directory is a Terraform child module. The root main.tf calls it and passes in the object IDs that the policy files need:

module "policies" {
  source = "./Policies"

  breakglass_group_object_id               = azuread_group.breakglass.object_id
  admin_persona_group_object_id            = azuread_group.admin_persona.object_id
  internals_persona_group_object_id        = azuread_group.internals_persona.object_id
  service_accounts_persona_group_object_id = azuread_group.service_accounts_persona.object_id
  eu_named_location_id                     = azuread_named_location.eu.id
  eu_named_location_object_id              = azuread_named_location.eu.object_id
}

This keeps the policy files inside Policies/ free of root-level resource references. Each policy file receives group and location IDs as module input variables and references them as var.breakglass_group_object_id, var.internals_persona_group_object_id, and so on. The boundary makes it straightforward to review planned policy changes in isolation from the supporting infrastructure.

The actual Policies/ directory contains more files, but the naming convention is consistent:

CA###-Persona-Control-Apps-Platform-Outcome.tf

The sequence is grouped by persona:

Series

Purpose

CA000

Global baseline controls

CA100

Admin persona controls

CA200

Internal user controls

CA300

Service account controls

CA400

Guest user controls

Representative Policies

Instead of walking through every policy line by line, it is more useful to look at representative examples.

Global MFA Baseline

CA000 applies MFA to all users and all applications, while excluding emergency access and selected policy exclusion groups:

users {
  included_users = ["All"]
  excluded_groups = [
    var.breakglass_group_object_id,
    azuread_group.ca000_exclusion.object_id,
    azuread_group.ca100_exclusion.object_id,
    azuread_group.ca200_exclusion.object_id,
    azuread_group.ca300_exclusion.object_id,
    azuread_group.ca400_exclusion.object_id,
  ]
}

grant_controls {
  operator                          = "OR"
  authentication_strength_policy_id = "/policies/authenticationStrengthPolicies/00000000-0000-0000-0000-000000000002" # MFA
}

This is the broad baseline. More specific policies can add stronger controls for particular personas.

Country Whitelist Block

CA001 blocks access from outside the named EU location:

locations {
  included_locations = ["All"]
  excluded_locations = [var.eu_named_location_id]
}

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

This policy is an example of using a named location as a negative condition. Everything outside the expected geography is blocked, while emergency access remains excluded.

Admin Phishing-Resistant MFA

CA100 targets the admin persona group and requires phishing-resistant MFA:

users {
  included_groups = [var.admin_persona_group_object_id]
  excluded_groups = [
    var.breakglass_group_object_id,
    azuread_group.ca100_exclusion.object_id,
  ]
}

grant_controls {
  operator                          = "OR"
  authentication_strength_policy_id = "/policies/authenticationStrengthPolicies/00000000-0000-0000-0000-000000000004"
}

We used the built-in authentication strength and exclude the emergency access group.

Blocking Authentication Flows

Certain authentication flows should be blocked and only permitted for defined exceptions. The framework defines two separate policies to block using the individual controls:

authentication_flow_transfer_methods = ["deviceCodeFlow"]
authentication_flow_transfer_methods = ["authenticationTransfer"]

Splitting these into separate policies makes report-only analysis easier. You can see which control would have affected a sign-in without having to infer which method caused the match and also allows exception for the specific flow without having to grant exception for both at the same time.


The full code can be found here: https://github.com/DirgoSalga/terraform_cap_framework/

Deploy Safely

Conditional Access policies can lock users out. Treat deployment as a staged rollout, not as a one-step switch.

The state field supports these values:

  • disabled
  • enabledForReportingButNotEnforced
  • enabled

For new policies, start with:

state = "enabledForReportingButNotEnforced"

Then use sign-in logs, report-only results, and the What If tool to confirm behavior. Only move a policy to enabled after testing the intended users, excluded users, applications, and emergency access paths.

The normal local workflow is:

terraform fmt -recursive
terraform validate
terraform plan
terraform apply

IBefore applying to production, verify at least these cases:

  • Emergency access accounts can still sign in.
  • Policy-specific exclusion groups work as expected.
  • Report-only events match your expectations.
  • Block policies do not catch administrative break-glass paths.
  • Service accounts are not unintentionally affected by user-scoped policies.

Continuous Enforcement with Pipelines

Once the configuration is stable, the same service principal used for local testing can be used from a pipeline. The usual workflow is:

  1. Pull request opens.
  2. Pipeline runs terraform fmt, terraform validate, and terraform plan.
  3. Reviewers inspect the policy diff and planned changes.
  4. Merge to the main branch triggers terraform apply.

This makes the pipeline the preferred path for policy changes. Manual portal edits can still happen, but they show up as drift during the next plan. The team can then decide whether to bring the portal change into code or revert it through Terraform.

Remote state should be introduced before multiple people or a pipeline start applying changes. For a production setup, store state in a backend with locking and access controls.

AI-Assisted Authoring with the Terraform MCP Server

Writing Conditional Access policies by hand requires knowing the provider schema, accepted values, and Microsoft Graph behavior. The Terraform MCP server can help by connecting an AI assistant directly to Terraform Registry documentation.

That makes it useful for:

  • Checking provider arguments and nested blocks.
  • Finding current provider versions.
  • Verifying whether a setting is supported by the provider.
  • Generating a first draft of a resource block.
  • Comparing code against provider documentation during review.

It does not replace review or testing. It reduces lookup time, but the final policy still needs to be validated with Terraform and tested in Microsoft Entra report-only mode.

What Is Next

I intentionally left some topics out, that you might want to investigate (or leave me a comment below).

  • Importing existing Conditional Access policies into Terraform.
  • Remote state design.
  • CI/CD implementation details for Azure DevOps or GitHub Actions.
  • Advanced custom authentication strengths.
  • Restricting administrative access to a certain type of device: Privileged Access Workstations (PAW).

Those are good follow-up topics once the baseline pattern is clear.

Conclusion

Managing Conditional Access through Terraform gives you a reviewable and repeatable way to control one of the most sensitive parts of a Microsoft Entra tenant. The key is not only writing policy resources. The key is building the supporting structure: emergency access, named locations, persona groups, policy-specific exclusions and a workflow that makes drift visible.

Start small, validate each policy in report-only mode, and treat Conditional Access changes with the same discipline as other production security controls.