~/2025/08/06/powershell-entra-id-audit-conditional-access-policy-drift.md
PowerShell: Entra ID – Audit Conditional Access Policy Drift
--- author: Tom Lasswell date: read: 4 min in: [ps, scripts] tags: [powershell, entra-id] ---
$ grep -n '^#' post.md
Conditional Access policies drift more than most admins want to admit. Someone adds an emergency exclusion during an incident and forgets to remove it, a break-glass group quietly drops off a policy it should be excluded from, or a "temporary" pilot policy never gets reverted. None of this shows up unless you go looking. The audit log records each change, but Entra ID doesn't give you a diff between what a policy looks like today and what it looked like when it was last reviewed. I run this script on a schedule against a baseline snapshot so drift shows up as a report instead of an incident. It reports each changed setting by path (for example conditions.users.excludeGroups, value added), not a wall of JSON, and it can flag any enabled policy that doesn't exclude your emergency access group.
It's the audit half of Intune: Conditional Access Policies That Don't Lock Out Your Help Desk: take the baseline right after those policies are rolled out and reviewed.
Requirements
- PowerShell 7 or later (the script uses
ConvertFrom-Json -AsHashtable). - The
Microsoft.Graph.Authenticationmodule from the Microsoft Graph PowerShell SDK (Install-Module Microsoft.Graph.Authentication -Scope CurrentUser). The script calls the Graph REST endpoint throughInvoke-MgGraphRequest, so no other SDK module is needed. - Microsoft Graph permission
Policy.Read.All, the least-privileged permission for listing Conditional Access policies (delegated or application). - For delegated use, the signed-in account needs a role that can read Conditional Access policies: Security Reader, Global Reader, Security Administrator, Conditional Access Administrator, or Global Secure Access Administrator.
- A writable path for the baseline JSON file and, optionally, the CSV drift report.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
BaselinePath | string | Yes | Path to the baseline JSON file. Created by a -Snapshot run, read by a comparison run. |
OutputPath | string | No | Path to write a CSV drift report. If omitted, drift is only written to the pipeline. |
Snapshot | switch | No | Captures the current policy state as the new baseline instead of comparing against one. |
EmergencyAccessGroupId | string | No | Object ID of the emergency access group. When set, every enabled user-targeted policy that doesn't exclude it is reported as Missing exclusion. |
FailOnDrift | switch | No | Exits with code 1 when any drift is found, so a scheduled task or pipeline can alert on it. |
Usage
Capture the first baseline (do this right after a policy review, not on an arbitrary day):
.\Get-ConditionalAccessDrift.ps1 -BaselinePath "C:\Audits\ca-baseline.json" -Snapshot
Compare the current tenant state against that baseline, check break-glass exclusions, and write a drift report:
.\Get-ConditionalAccessDrift.ps1 -BaselinePath "C:\Audits\ca-baseline.json" -EmergencyAccessGroupId "<emergency-access-group-object-id>" -OutputPath "C:\Audits\ca-drift-2025-08-06.csv"
Run it unattended with an app registration and a certificate (grant Policy.Read.All as an application permission first), failing the job when anything changed:
Connect-MgGraph -ClientId "<app-client-id>" -TenantId "<tenant-id>" -CertificateThumbprint "<thumbprint>" -NoWelcome
.\Get-ConditionalAccessDrift.ps1 -BaselinePath "D:\Audits\ca-baseline.json" -EmergencyAccessGroupId "<emergency-access-group-object-id>" -FailOnDrift
Sample output when drift is found:
Loaded 9 policies; baseline has 9.
WARNING: 4 drift item(s) detected.
PolicyName Change Setting Detail
---------- ------ ------- ------
CA002 - All users - Block legacy authentication Setting changed state Baseline: enabled | Current: enabledForReportingButNotEnforced
CA003 - All users - Require MFA strength Value added conditions.users.excludeGroups 00000000-0000-0000-0000-000000000042
CA006 - All users - Windows compliant or hybrid Value removed grantControls.builtInControls domainJoinedDevice
CA004 - All users - Require MFA for Azure mana… Missing exclusion conditions.users.excludeGroups Enabled policy does not exclude emergency access group <emergency-access-group-object-id>.
Script
<#
.SYNOPSIS
Snapshots Entra ID Conditional Access policies and reports drift against a baseline.
.DESCRIPTION
Reads every Conditional Access policy from the Microsoft Graph v1.0 REST endpoint (following
paging), flattens each policy into sorted setting paths such as conditions.users.excludeGroups,
and either writes them as a new baseline (-Snapshot) or compares them with an existing baseline.
Drift is reported per setting: policy added or removed, setting changed, and values added to or
removed from a list. Optionally reports enabled policies that don't exclude the emergency access
group, and exports the report to CSV.
.PARAMETER BaselinePath
Path to the baseline JSON file. Created by a -Snapshot run, read by a comparison run.
.PARAMETER OutputPath
Path to write a CSV drift report. If omitted, drift is only written to the pipeline.
.PARAMETER Snapshot
Captures the current policy state as the new baseline instead of comparing against one.
.PARAMETER EmergencyAccessGroupId
Object ID of the emergency access group. When set, every enabled user-targeted policy that doesn't
exclude it is reported as Missing exclusion.
.PARAMETER FailOnDrift
Exits with code 1 when any drift is found, so a scheduled task or pipeline can alert on it.
.EXAMPLE
.\Get-ConditionalAccessDrift.ps1 -BaselinePath "C:\Audits\ca-baseline.json" -Snapshot
.EXAMPLE
.\Get-ConditionalAccessDrift.ps1 -BaselinePath "C:\Audits\ca-baseline.json" -EmergencyAccessGroupId "<group-object-id>" -OutputPath "C:\Audits\ca-drift.csv"
.NOTES
Author : Thomas Lasswell (https://www.techcolumnist.com)
Version : 1.0 (2025-08-06)
Requires: PowerShell 7+, Microsoft.Graph.Authentication module, Policy.Read.All
#>
[CmdletBinding()]
param (
[Parameter(Mandatory = $true)]
[string]$BaselinePath,
[string]$OutputPath,
[switch]$Snapshot,
[string]$EmergencyAccessGroupId,
[switch]$FailOnDrift
)
$ErrorActionPreference = 'Stop'
Import-Module Microsoft.Graph.Authentication
# Connect if there is no session, or if a delegated session lacks the required scope.
$context = Get-MgContext
if (-not $context -or ($context.AuthType -eq 'Delegated' -and $context.Scopes -notcontains 'Policy.Read.All')) {
Connect-MgGraph -Scopes 'Policy.Read.All' -NoWelcome
}
# Keys that change without anyone editing the policy (OData annotations are skipped by pattern).
$ignoredKeys = @('createdDateTime', 'modifiedDateTime')
# Flatten a policy into path = value pairs. Lists are sorted so reordering is not drift, and
# empty lists and nulls are skipped because Graph returns both for "not configured".
function ConvertTo-FlatSetting {
param (
$Value,
[string]$Path,
[System.Collections.IDictionary]$Result
)
if ($null -eq $Value) {
return
}
if ($Value -is [System.Collections.IDictionary]) {
# Keep only the identity of an authentication strength; Microsoft updates the built-in combinations.
if ($Path -like '*.authenticationStrength') {
$Result["$Path.id"] = [string]$Value['id']
$Result["$Path.displayName"] = [string]$Value['displayName']
return
}
foreach ($key in ($Value.Keys | Sort-Object)) {
if ($ignoredKeys -contains $key -or $key -like '*@odata.*') {
continue
}
$childPath = if ($Path) { "$Path.$key" } else { [string]$key }
ConvertTo-FlatSetting -Value $Value[$key] -Path $childPath -Result $Result
}
return
}
if ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [string]) {
$items = @($Value)
if ($items.Count -eq 0) {
return
}
if (@($items | Where-Object { $_ -is [System.Collections.IDictionary] }).Count -gt 0) {
for ($i = 0; $i -lt $items.Count; $i++) {
ConvertTo-FlatSetting -Value $items[$i] -Path "$($Path)[$i]" -Result $Result
}
return
}
$Result[$Path] = @($items | ForEach-Object { [string]$_ } | Sort-Object)
return
}
$Result[$Path] = [string]$Value
}
function Get-CaPolicySetting {
$uri = 'https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies'
while ($uri) {
$page = Invoke-MgGraphRequest -Method GET -Uri $uri
foreach ($policy in $page['value']) {
$settings = [ordered]@{}
ConvertTo-FlatSetting -Value $policy -Path '' -Result $settings
[PSCustomObject]@{
Id = $policy['id']
DisplayName = $policy['displayName']
State = $policy['state']
ModifiedDateTime = [string]$policy['modifiedDateTime']
Settings = $settings
}
}
$uri = $page['@odata.nextLink']
}
}
# Turn a possibly-null value into an array without wrapping $null as an item.
function ConvertTo-ItemList {
param ($Value)
if ($null -eq $Value) {
return , @()
}
return , @($Value)
}
$current = @(Get-CaPolicySetting)
if ($Snapshot) {
# -InputObject keeps a one-policy tenant serialized as an array.
ConvertTo-Json -InputObject $current -Depth 6 | Set-Content -Path $BaselinePath -Encoding utf8
Write-Host "Baseline written to $BaselinePath ($($current.Count) policies)."
return
}
if (-not (Test-Path -Path $BaselinePath)) {
throw "Baseline file '$BaselinePath' not found. Run again with -Snapshot to create one."
}
$baseline = @(Get-Content -Path $BaselinePath -Raw | ConvertFrom-Json -AsHashtable)
Write-Host "Loaded $($current.Count) policies; baseline has $($baseline.Count)."
$baselineById = @{}
foreach ($item in $baseline) {
$baselineById[$item.Id] = $item
}
$currentById = @{}
foreach ($item in $current) {
$currentById[$item.Id] = $item
}
$drift = [System.Collections.Generic.List[PSCustomObject]]::new()
function Add-Drift {
param (
[string]$PolicyName,
[string]$Change,
[string]$Setting,
[string]$Detail
)
$drift.Add([PSCustomObject]@{
PolicyName = $PolicyName
Change = $Change
Setting = $Setting
Detail = $Detail
})
}
# Policies that are new or changed since the baseline.
foreach ($policy in $current) {
$match = $baselineById[$policy.Id]
if (-not $match) {
Add-Drift -PolicyName $policy.DisplayName -Change 'Added' -Setting '' -Detail "Not in the baseline. Last modified $($policy.ModifiedDateTime)."
continue
}
$paths = (@($policy.Settings.Keys) + @($match.Settings.Keys)) | Sort-Object -Unique
foreach ($path in $paths) {
$old = $match.Settings[$path]
$new = $policy.Settings[$path]
$isList = ($old -is [System.Collections.IEnumerable] -and $old -isnot [string]) -or
($new -is [System.Collections.IEnumerable] -and $new -isnot [string])
if ($isList) {
$oldItems = ConvertTo-ItemList -Value $old
$newItems = ConvertTo-ItemList -Value $new
foreach ($value in ($newItems | Where-Object { $oldItems -notcontains $_ })) {
Add-Drift -PolicyName $policy.DisplayName -Change 'Value added' -Setting $path -Detail $value
}
foreach ($value in ($oldItems | Where-Object { $newItems -notcontains $_ })) {
Add-Drift -PolicyName $policy.DisplayName -Change 'Value removed' -Setting $path -Detail $value
}
} elseif ([string]$old -cne [string]$new) {
$oldText = if ($null -eq $old) { '(not set)' } else { $old }
$newText = if ($null -eq $new) { '(not set)' } else { $new }
Add-Drift -PolicyName $policy.DisplayName -Change 'Setting changed' -Setting $path -Detail "Baseline: $oldText | Current: $newText"
}
}
}
# Policies that were in the baseline but have since been removed.
foreach ($item in $baseline) {
if (-not $currentById[$item.Id]) {
Add-Drift -PolicyName $item.DisplayName -Change 'Removed' -Setting '' -Detail 'In the baseline but no longer in the tenant.'
}
}
# Enabled, user-targeted policies that don't exclude the emergency access group.
if ($EmergencyAccessGroupId) {
foreach ($policy in ($current | Where-Object { $_.State -eq 'enabled' })) {
$targetsUsers = @($policy.Settings.Keys | Where-Object { $_ -like 'conditions.users.include*' }).Count -gt 0
$excluded = ConvertTo-ItemList -Value $policy.Settings['conditions.users.excludeGroups']
if ($targetsUsers -and $excluded -notcontains $EmergencyAccessGroupId) {
Add-Drift -PolicyName $policy.DisplayName -Change 'Missing exclusion' -Setting 'conditions.users.excludeGroups' -Detail "Enabled policy does not exclude emergency access group $EmergencyAccessGroupId."
}
}
}
if ($drift.Count -eq 0) {
Write-Host "No drift detected across $($current.Count) Conditional Access policies."
} else {
Write-Warning "$($drift.Count) drift item(s) detected."
$drift
if ($OutputPath) {
$drift | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding utf8
Write-Host "Drift report written to $OutputPath."
}
if ($FailOnDrift) {
exit 1
}
}
Notes
- Why raw REST instead of
Get-MgIdentityConditionalAccessPolicy. Serializing the SDK's model objects withConvertTo-Jsonties the baseline's shape to the SDK version (a module update can add or rename properties and show up as drift on every policy), and a singleConvertTo-Json -Depthvalue silently truncates anything nested deeper. The script reads the documented v1.0 JSON throughInvoke-MgGraphRequestand compares flattened paths, so neither problem applies. - Reordering is not drift. Graph doesn't promise a stable order for lists such as
excludeGroups, so the script sorts every list before comparing, and reports list changes as individual values added or removed. - Authentication strengths are reduced to their ID and name. Microsoft updates the built-in authentication strengths when new methods become available, and the policy response can expand the strength's allowed combinations. Comparing those would report drift nobody made.
- Microsoft-managed policies change on their own. Microsoft creates and updates these policies (they show Microsoft in the Created by column) and automatically adds newly eligible users, groups, or workloads to their scope, so expect them in the report. Microsoft documents an audit query for its own changes, which needs
AuditLog.Read.All:GET https://graph.microsoft.com/v1.0/auditLogs/directoryAudits?$filter=initiatedBy/app/displayName eq 'Microsoft Managed Policy Manager' and category eq 'Policy'. - Who made a change. The script finds what changed; the audit log says who. In the Entra admin center, open Monitoring & health > Audit logs and set Service to Conditional Access (Reports Reader is enough there).
- The emergency access check. Microsoft recommends excluding emergency access accounts from Conditional Access policies that block or restrict sign-in, and says report-only policies don't need the exclusion. That's why the check only looks at
enabledpolicies, and skips workload-identity policies that don't target users. - Scheduling. Grant
Policy.Read.Allas an application permission and authenticate with a certificate or a managed identity, not a client secret stored in a script. With-FailOnDrift,pwsh -Filereturns exit code 1 on drift, which Task Scheduler, Azure Automation, or a CI pipeline can alert on. - Treat the baseline like security configuration. Store it in source control or a controlled share, and re-snapshot deliberately after a reviewed change, never automatically. A baseline that refreshes itself on every run hides exactly the drift you're looking for.
- This audits policy configuration, not sign-in outcomes. Pair it with the sign-in logs or the What If tool when you need to know how a policy actually behaved for a given user.
Source
- Intune: Conditional Access Policies That Don't Lock Out Your Help Desk: the policy baseline this script audits.
- List Conditional Access policies (Microsoft Graph v1.0): permissions and supported roles.
- conditionalAccessPolicy resource type
- Overview of Conditional Access authentication strengths
- Microsoft-managed Conditional Access policies
- Manage emergency access admin accounts