Conditional Access (CA) policies are the backbone of identity security in Microsoft Entra ID. A single misconfigured rule can lock administrators out of a tenant, while an accidental deletion can wipe out years of tuned exceptions, named locations, and session controls. Yet many organisations still treat these policies as “configured once, forgotten forever” – with no version history and no recovery path.

In this post we will build a lightweight PowerShell backup script that exports every Conditional Access policy to timestamped JSON files, commits the result to a Git repository, and can be scheduled as an Azure Automation runbook or GitHub Actions job. The approach gives you a searchable audit trail, configuration drift detection, and a reliable restore option without paying for an extra product.

Prerequisites

  • PowerShell 7.4 or later (Windows, Linux, or macOS)
  • The Microsoft.Graph.Authentication and Microsoft.Graph.Identity.SignIns modules, installed with Install-Module Microsoft.Graph -Scope CurrentUser
  • An Entra role that can read policies: Global Reader, Security Reader, or Conditional Access Administrator
  • For unattended runs, an app registration with the Policy.Read.All application permission and a client certificate (never a secret in production)
  • Git installed if you want the automatic commit step

Step 1: Connect to Microsoft Graph

Interactive runs work nicely with device code flow, while scheduled runs should authenticate with a certificate. Wrap both in one function so the script can be tested locally and then dropped into automation unchanged.

function Connect-CaBackup {
    [CmdletBinding()]
    param(
        [string]$TenantId,
        [string]$ClientId,
        [string]$CertificateThumbprint
    )

    if ($ClientId -and $CertificateThumbprint) {
        Connect-MgGraph -TenantId $TenantId `
                        -ClientId $ClientId `
                        -CertificateThumbprint $CertificateThumbprint `
                        -NoWelcome
    }
    else {
        Connect-MgGraph -Scopes 'Policy.Read.All' -NoWelcome
    }

    $ctx = Get-MgContext
    Write-Host "Connected as $($ctx.Account) to tenant $($ctx.TenantId)"
}

The NoWelcome switch keeps log output tidy, which matters once the script is running headless.

Step 2: Export Every Policy to JSON

The cmdlet Get-MgIdentityConditionalAccessPolicy returns every policy as a rich object. Converting it with ConvertTo-Json -Depth 10 preserves the nested conditions, grant controls, and session settings. A per-policy file is easier to diff than a single large blob.

function Export-CaPolicies {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$OutputFolder
    )

    if (-not (Test-Path $OutputFolder)) {
        New-Item -ItemType Directory -Path $OutputFolder -Force | Out-Null
    }

    $policies = Get-MgIdentityConditionalAccessPolicy -All
    Write-Host "Found $($policies.Count) Conditional Access policies"

    foreach ($policy in $policies) {
        $safeName = ($policy.DisplayName -replace '[\/:*?"<>|]', '_').Trim()
        $file     = Join-Path $OutputFolder "$safeName.json"

        $policy | ConvertTo-Json -Depth 10 |
            Set-Content -Path $file -Encoding utf8
    }

    # Also write a manifest so you can spot deletions easily
    $manifest = $policies | Select-Object Id, DisplayName, State, ModifiedDateTime
    $manifest | ConvertTo-Json -Depth 3 |
        Set-Content -Path (Join-Path $OutputFolder '_manifest.json') -Encoding utf8
}

The regex in safeName strips characters that Windows and Linux disagree about, so the same repository works cleanly on both platforms. The manifest file lists every policy ID, state, and last modified timestamp, which makes it trivial to detect deletions between runs.

Step 3: Commit Changes to Git

Backing up without version history is only half the job. Piping the export folder through Git turns every run into an auditable record: who added a policy, when a scope changed, and exactly which controls were removed.

function Save-CaBackup {
    param(
        [Parameter(Mandatory)]
        [string]$RepoPath,
        [string]$CommitMessage = "CA backup $(Get-Date -Format 'yyyy-MM-dd HH:mm')"
    )

    Push-Location $RepoPath
    try {
        git add --all
        $pending = git status --porcelain
        if (-not $pending) {
            Write-Host 'No policy changes detected, skipping commit'
            return
        }

        git commit -m $CommitMessage | Out-Null
        git push                      | Out-Null
        Write-Host "Committed changes: $CommitMessage"
    }
    finally {
        Pop-Location
    }
}

The early return when git status is empty avoids a cluttered history full of no-op commits. In a CI pipeline you can add tags or open a pull request instead of pushing directly to main, which gives security reviewers a chance to approve changes before they are recorded as the new baseline.

Putting It All Together

The three functions compose into a single unattended runbook. Parameterise the paths and credentials so the same file works in development, staging, and production tenants.

param(
    [string]$TenantId              = $env:CA_TENANT_ID,
    [string]$ClientId              = $env:CA_CLIENT_ID,
    [string]$CertificateThumbprint = $env:CA_CERT_THUMBPRINT,
    [string]$RepoPath              = 'C:\Backups\EntraCA'
)

. $PSScriptRoot\CaBackupFunctions.ps1

Connect-CaBackup -TenantId $TenantId `
                 -ClientId $ClientId `
                 -CertificateThumbprint $CertificateThumbprint

Export-CaPolicies -OutputFolder (Join-Path $RepoPath 'policies')
Save-CaBackup     -RepoPath    $RepoPath

Disconnect-MgGraph | Out-Null

Schedule the script every hour with Azure Automation, a Windows scheduled task, or a GitHub Actions workflow running on a self hosted runner. The full backup completes in under a minute for most tenants, and the resulting repository becomes a searchable history of every policy change in your estate.

Closing Thoughts

Conditional Access is too important to leave without version control. With three short PowerShell functions and a Git repository you get change tracking, a restore path, and a reviewable audit trail, all running on infrastructure you already own. Extend the script with Named Locations, Authentication Strengths, and Authentication Contexts by adding extra Get-Mg calls, and you will have a complete snapshot of your identity perimeter ready for every compliance review.