Conditional Access is the most powerful and least-audited feature in Entra ID. The portal view is friendly but hides the gaps: a policy in Report-only mode looks identical to one in Enforce, an exclude-group can quietly grow over the years, and "All cloud apps" never quite means all of them. The Graph SDK gives you the raw policy objects so you can audit them like code.
Table of Contents
Pull every CA policy
Connect-MgGraph -Scopes 'Policy.Read.All','Directory.Read.All'
$pol = Get-MgIdentityConditionalAccessPolicy -All
$pol | Select DisplayName, State, CreatedDateTime, ModifiedDateTime |
Sort State, DisplayName | Format-Table
Three states matter: enabled (enforcing), enabledForReportingButNotEnforced (audit only) and disabled (off). A "covered" CA gap usually turns out to be a policy that has sat in Report-only for two years.
State and gaps
$pol | Group State | Select Name, Count
$pol | Where State -ne 'enabled' | Select DisplayName, State, ModifiedDateTime
MFA-for-all check
$mfaPolicies = $pol | Where {
$_.State -eq 'enabled' -and
$_.GrantControls.BuiltInControls -contains 'mfa'
}
$mfaForAll = $mfaPolicies | Where {
$_.Conditions.Users.IncludeUsers -contains 'All' -and
$_.Conditions.Applications.IncludeApplications -contains 'All'
}
if (-not $mfaForAll) { Write-Warning 'No MFA-for-all-users-and-apps CA policy is enforcing.' }
Block legacy auth check
$blockLegacy = $pol | Where {
$_.State -eq 'enabled' -and
$_.GrantControls.BuiltInControls -contains 'block' -and
$_.Conditions.ClientAppTypes -contains 'exchangeActiveSync' -and
$_.Conditions.ClientAppTypes -contains 'other'
}
if (-not $blockLegacy) { Write-Warning 'No CA policy blocks legacy authentication.' }
Exclude-group hygiene
foreach ($p in $pol | Where State -eq 'enabled') {
foreach ($g in $p.Conditions.Users.ExcludeGroups) {
$grp = Get-MgGroup -GroupId $g
$count = (Get-MgGroupMember -GroupId $g -All).Count
[pscustomobject]@{
Policy = $p.DisplayName
Excluded = $grp.DisplayName
Members = $count
}
}
} | Where Members -gt 5
Any exclude group with more than five members deserves a written justification. The classic finding: an "MFA exempt" group started with two service accounts and grew to 70.
Use What-If for change review
Before any CA change, run the Microsoft What If tool against representative users (a normal user, an admin, a guest, a service principal). The Graph SDK exposes the same simulation via POST /identity/conditionalAccess/policies/whatIf.
Audit checklist
- Zero policies stuck in Report-only > 30 days (1 pt)
- MFA-for-all-users-and-apps policy enabled (1 pt)
- Block-legacy-auth policy enabled (1 pt)
- Privileged users covered by a stricter sign-in risk policy (1 pt)
- Exclude groups documented and < 5 members each (1 pt)
Treating Conditional Access as code
Hand-edited Conditional Access policies drift, conflict, and create gaps. The defensible pattern is to treat policies as JSON in source control: export, diff, review, deploy. The audit script is then a watchdog that flags any policy in the tenant that does not match the repository.
Export every policy as canonical JSON
$policies = Get-MgIdentityConditionalAccessPolicy -All
foreach ($p in $policies) {
$json = $p | ConvertTo-Json -Depth 20
$name = ($p.DisplayName -replace '[^\w\-]','_')
$json | Out-File -FilePath ".\ca\$name.json" -Encoding utf8
}
Commit the output. The first run is the baseline; from then on every change to a policy generates a real diff in code review.
Detect dangerous configurations
A handful of policy shapes are almost always wrong and deserve a hard fail in CI:
- Policy in report-only for more than 14 days. Either enforce or remove.
- Policy excluding All Users with no other targeting โ effectively disabled.
- Policy with no MFA control on a high-risk app.
- More than two break-glass accounts in the global exclusion list.
$problems = $policies | ForEach-Object {
$issues = @()
if ($_.State -eq 'enabledForReportingButNotEnforced' -and
((Get-Date) - $_.ModifiedDateTime).Days -gt 14) { $issues += 'stale-report-only' }
if ($_.Conditions.Users.ExcludeUsers.Count -gt 2) { $issues += 'too-many-exclusions' }
if ($_.GrantControls.BuiltInControls -notcontains 'mfa' -and
$_.Conditions.Applications.IncludeApplications -contains 'All') {
$issues += 'no-mfa-on-all-apps'
}
if ($issues) { [pscustomobject]@{ Policy=$_.DisplayName; Issues=$issues -join ', ' } }
}
$problems | Format-Table -AutoSize
What-if simulation before rollout
The Graph identity/conditionalAccess/policies/whatIf endpoint accepts a hypothetical user, app and condition set, and returns which policies would apply. Build a small set of representative scenarios (admin from home, contractor from abroad, service account from datacentre) and run them after every change.
Common pitfalls
- One mega-policy. A single policy that tries to cover every condition becomes impossible to reason about. Split by intent: one for admins, one for high-risk apps, one for legacy auth block.
- Trusting "report-only" for protection. Report-only logs would-be blocks but does not prevent anything. Review the report and promote to enabled within two weeks.
- Forgetting service principals. Conditional Access policies on workload identities (preview/GA depending on tenant) are a separate object. Audit them with
Get-MgIdentityConditionalAccessPolicytoo โ they appear in the same collection. - No version of the export script in source control. The script is the audit; if it lives only on one admin's laptop, the audit dies with that laptop.
- Skipping the legacy-auth block. Even in 2026, basic auth lingers in old SMTP clients. A Conditional Access policy that blocks legacy authentication is the single most effective control you can deploy.
CA-as-code: a CI pipeline you can build in a day
Treating Conditional Access as code becomes self-enforcing once it lives in CI. The minimum viable pipeline below uses GitHub Actions or Azure DevOps and takes about a day to stand up; from then on every policy change is reviewed, diffed and audited automatically.
- Repository structure. One JSON file per policy under
ca/policies/. One README per policy explaining its intent. Abaseline/folder for the export of "tenant truth" generated by the nightly job. - Nightly export job. Runs the export script with read-only Graph permissions, commits any drift to a branch named
drift/$(date), and opens a pull request. A merged PR is human acknowledgement that the change was intended. - Pre-merge validation. On every PR, run a linter that checks the danger-config rules from the audit (no stale report-only, no unbounded exclusion list, no missing MFA on broad apps). Fail the PR if any rule trips.
- What-if simulation. Build a small set of representative scenarios (admin from home, contractor, service principal). On every PR, run the Graph what-if endpoint with the proposed policy set and post the result as a PR comment.
- Deploy step. A merge to
maintriggers a deploy job that creates or updates each policy via Graph. Use--state enabledForReportingButNotEnforcedfor new policies; promote to enabled in a follow-up PR after report-only data confirms expected behaviour. - Drift alert. A weekly job re-exports and compares against
main. Any difference posts to the security channel โ usually it is an admin who clicked the portal "just to test", and now you know.
The first month feels slow because every change is a PR. By month three, the team has a complete change history, peer review on every policy edit, and a tenant that no longer drifts. Auditors love it; admins quickly come to prefer it.
Conditional Access health metrics over time
A Conditional Access deployment that nobody measures decays predictably. Policies multiply, exclusions accumulate, and the gap between "what we said we enforce" and "what we actually enforce" widens until the next audit reveals it. Three small dashboards keep the gap closed.
The first dashboard is policy state distribution. Plot the count of policies by state โ enabled, report-only, disabled โ over time. A healthy tenant has a small enabled set and a tiny report-only set; the latter exists only for policies in active development. Report-only policies older than two weeks are noise that hides real signal, and the chart of "report-only policies older than 14 days" trending toward zero is a one-glance health indicator.
The second is exclusion sprawl. Sum the unique users excluded across all policies. The number should be tiny โ break-glass accounts and a few documented exceptions. Watch the slope: any week-over-week growth deserves a question. The most common cause of growth is an admin who added a user to one policy's exclusion list to debug a problem and forgot to remove them.
The third is sign-in coverage. From the sign-in logs, count the percentage of interactive sign-ins that triggered at least one Conditional Access policy. A coverage percentage below 95% means there are sign-ins your policies do not see โ usually legacy authentication, often Exchange ActiveSync, occasionally a forgotten service account. Each gap is a finding that closes with a small policy edit.
Pair the dashboards with two automated jobs. A nightly export-and-diff job produces a daily activity feed of every policy change; the security team scans it in under a minute. A monthly what-if regression suite re-runs your representative scenarios against the current policy set; any scenario that produces a different outcome from last month's run flags a change worth understanding.
The investment is modest โ half a day to build the exports, another half to wire the dashboards โ and the returns compound. After six months the team trusts the dashboard more than the policies; after twelve, the auditors do too.
FAQ
What is the safest first CA policy?
"Block legacy auth" โ it is rarely needed and almost always abused.
Should the break-glass account bypass everything?
Yes, but only the break-glass โ and the account must use FIDO2, be monitored hourly, and be in its own exclude group with no other members.
How often should I review CA policies?
Every change is an audit event. Schedule a quarterly review even if no changes were made.
How do I diff two CA exports automatically?
git diff on the per-policy JSON files works well. For semantic diffs, jd or diffson render the changes as JSON patches that map cleanly to policy fields.
Can I use Terraform for Conditional Access?
The AzureAD provider supports it. Choose Terraform if your shop already uses it; the export-and-commit pattern works fine without it for smaller teams.
Does the audit catch named locations and trusted IPs?
Yes if you include Get-MgIdentityConditionalAccessNamedLocation in the export. Trusted-IP changes are a top-three configuration drift item โ track them.
What about Conditional Access for B2B guests?
Guests are subject to your tenant's policies. The cross-tenant access settings (Get-MgPolicyCrossTenantAccessPolicyDefault) are a separate audit target with their own JSON shape.