๐ŸŽ New User? Get 20% off your first purchase with code NEWUSER20 ยท โšก Instant download ยท ๐Ÿ”’ Secure checkout Register Now โ†’
Menu

Categories

Conditional Access Policy Audit with Microsoft Graph (2026)

Conditional Access Policy Audit with Microsoft Graph (2026)
Microsoft Graph conditional access audit - Dargslan 2026

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.

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

  1. Zero policies stuck in Report-only > 30 days (1 pt)
  2. MFA-for-all-users-and-apps policy enabled (1 pt)
  3. Block-legacy-auth policy enabled (1 pt)
  4. Privileged users covered by a stricter sign-in risk policy (1 pt)
  5. 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-MgIdentityConditionalAccessPolicy too โ€” 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.

  1. Repository structure. One JSON file per policy under ca/policies/. One README per policy explaining its intent. A baseline/ folder for the export of "tenant truth" generated by the nightly job.
  2. 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.
  3. 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.
  4. 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.
  5. Deploy step. A merge to main triggers a deploy job that creates or updates each policy via Graph. Use --state enabledForReportingButNotEnforced for new policies; promote to enabled in a follow-up PR after report-only data confirms expected behaviour.
  6. 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.

Related Dargslan resources

Share this article:
Dargslan Editorial Team (Dargslan)
About the Author

Dargslan Editorial Team (Dargslan)

Collective of Software Developers, System Administrators, DevOps Engineers, and IT Authors

Dargslan is an independent technology publishing collective formed by experienced software developers, system administrators, and IT specialists.

The Dargslan editorial team works collaboratively to create practical, hands-on technology books focused on real-world use cases. Each publication is developed, reviewed, and...

Programming Languages Linux Administration Web Development Cybersecurity Networking

Stay Updated

Subscribe to our newsletter for the latest tutorials, tips, and exclusive offers.