~/2025/08/06/powershell-entra-id-audit-conditional-access-policy-drift.md

PowerShell: Entra ID – Audit Conditional Access Policy Drift

---
author: 
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.Authentication module from the Microsoft Graph PowerShell SDK (Install-Module Microsoft.Graph.Authentication -Scope CurrentUser). The script calls the Graph REST endpoint through Invoke-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

NameTypeRequiredDescription
BaselinePathstringYesPath to the baseline JSON file. Created by a -Snapshot run, read by a comparison run.
OutputPathstringNoPath to write a CSV drift report. If omitted, drift is only written to the pipeline.
SnapshotswitchNoCaptures the current policy state as the new baseline instead of comparing against one.
EmergencyAccessGroupIdstringNoObject ID of the emergency access group. When set, every enabled user-targeted policy that doesn't exclude it is reported as Missing exclusion.
FailOnDriftswitchNoExits 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):

powershell
.\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:

powershell
.\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:

powershell
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:

text
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

powershell
<#
.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 with ConvertTo-Json ties 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 single ConvertTo-Json -Depth value silently truncates anything nested deeper. The script reads the documented v1.0 JSON through Invoke-MgGraphRequest and 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 enabled policies, and skips workload-identity policies that don't target users.
  • Scheduling. Grant Policy.Read.All as an application permission and authenticate with a certificate or a managed identity, not a client secret stored in a script. With -FailOnDrift, pwsh -File returns 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