Conditional Access policy export and review
A read-only PowerShell script that exports every Conditional Access policy to JSON, builds a CSV overview, and flags the patterns that most often cause lockouts or gaps.
The problem
Conditional Access policies pile up over time: report-only policies nobody switched on, disabled experiments, block policies without break-glass exclusions. The portal shows them one by one, which makes a full review slow and a before-and-after comparison almost impossible.
Approach
Read all policies through Microsoft Graph PowerShell with the Policy.Read.All scope, save each one as a JSON file for versioning and diffing, write a one-row-per-policy CSV, and run a handful of checks: policies not enforced, block policies on all users without exclusions, and whether legacy authentication is blocked at all.
Implementation
The script needs the Microsoft Graph PowerShell SDK. Install it once with Install-Module Microsoft.Graph -Scope CurrentUser, then sign in with an account that holds at least the Security Reader or Global Reader role.
The script
#Requires -Modules Microsoft.Graph.Authentication, Microsoft.Graph.Identity.SignIns
param(
[string]$OutputPath = ".\ca-export-$(Get-Date -Format 'yyyy-MM-dd')"
)
Connect-MgGraph -Scopes "Policy.Read.All" -NoWelcome
$policies = Get-MgIdentityConditionalAccessPolicy -All
New-Item -ItemType Directory -Path "$OutputPath\policies" -Force | Out-Null
# 1. One JSON file per policy, for version control and diffing
foreach ($p in $policies) {
$safeName = ($p.DisplayName -replace '[\\/:*?"<>|]', '_')
$p | ConvertTo-Json -Depth 10 | Out-File "$OutputPath\policies\$safeName.json" -Encoding utf8
}
# 2. One row per policy
$rows = foreach ($p in $policies) {
$u = $p.Conditions.Users
[pscustomobject]@{
Name = $p.DisplayName
State = $p.State
IncludeUsers = ($u.IncludeUsers + $u.IncludeGroups + $u.IncludeRoles) -join '; '
ExcludeUsers = ($u.ExcludeUsers + $u.ExcludeGroups) -join '; '
Applications = $p.Conditions.Applications.IncludeApplications -join '; '
ClientAppTypes = $p.Conditions.ClientAppTypes -join '; '
GrantControls = $p.GrantControls.BuiltInControls -join '; '
AuthStrength = $p.GrantControls.AuthenticationStrength.DisplayName
Modified = $p.ModifiedDateTime
}
}
$rows | Export-Csv "$OutputPath\policies.csv" -NoTypeInformation -Encoding utf8
# 3. Review checks
$findings = @()
foreach ($p in $policies) {
$u = $p.Conditions.Users
if ($p.State -ne 'enabled') {
$findings += "[$($p.State)] $($p.DisplayName) is not enforced."
}
$blocks = $p.GrantControls.BuiltInControls -contains 'block'
$allUsers = $u.IncludeUsers -contains 'All'
$noExcl = -not ($u.ExcludeUsers -or $u.ExcludeGroups)
if ($p.State -eq 'enabled' -and $blocks -and $allUsers -and $noExcl) {
$findings += "[LOCKOUT RISK] $($p.DisplayName) blocks all users with no exclusions (no break-glass account)."
}
}
$legacyBlocked = $policies | Where-Object {
$_.State -eq 'enabled' -and
$_.GrantControls.BuiltInControls -contains 'block' -and
$_.Conditions.ClientAppTypes -contains 'exchangeActiveSync' -and
$_.Conditions.ClientAppTypes -contains 'other'
}
if (-not $legacyBlocked) {
$findings += "[GAP] No enabled policy blocks legacy authentication (Exchange ActiveSync and other clients)."
}
$findings | Out-File "$OutputPath\findings.txt" -Encoding utf8
$findings | ForEach-Object { Write-Host $_ }
Write-Host "`nExported $($policies.Count) policies to $OutputPath"
Disconnect-MgGraph | Out-Null
What you get
policies\*.json: the full definition of every policy. Commit these to a private repository and every change shows up as a diff.policies.csv: one row per policy, easy to filter in Excel during a review.findings.txt: the checks that need attention.
The checks
- Not enforced: policies in report-only or disabled state. Report-only is fine during a rollout, but should not stay that way for months.
- Lockout risk: an enabled block policy that targets all users with no excluded users or groups. One misconfiguration away from locking out every administrator.
- Legacy authentication gap: no enabled policy blocks Exchange ActiveSync and other legacy clients, which still bypass MFA.
Scope: the checks are deliberately simple. Exclusions via roles, named locations and authentication strengths are exported for review, but not judged automatically.
Security considerations
The script only reads; it requests Policy.Read.All and nothing that can change a policy. The exports contain user, group and role object IDs and the exact exclusions, which is useful information for an attacker, so keep them in a private location and never commit them to a public repository. Run it from a privileged access workstation if your organisation uses one.