
PART 1 OF 2
Configuration Drift Management in Microsoft Intune
A practical guide to detecting and managing configuration drift using PowerShell, Power Automate, Graph API, Intune Remediations, and free community tools. No expensive third-party tools or additional licensing required.
Audience: Intermediate IT Pros · Prerequisites: Microsoft Intune, Entra ID, basic PowerShell
If you’ve been managing devices with Microsoft Intune for any meaningful length of time, you’ll already know the feeling. You spend weeks getting your compliance policies, configuration profiles, and app assignments tuned to perfection. Everything is green in the dashboard. You declare victory.
Then, three months later, someone opens a ticket. A device is suddenly non-compliant. A security baseline has silently drifted. A profile conflict has crept in. Somehow, between your triumphant ‘we’re done here’ moment and right now, your carefully configured fleet has developed opinions of its own. Welcome to configuration drift.
This is Part 1 of a two-part series. Here we’ll cover the practical, mostly free approaches available to IT Pros managing drift with Intune tooling — PowerShell, Power Automate, Graph API, Intune’s own built-in features and a selection of free community tools that do a lot of the heavy lifting for you. These are not workarounds or stopgaps — they are genuinely capable approaches that many mature Intune environments rely on. Then in Part 2 I’ll cover my experience with Microsoft’s Unified Tenant Configuration Management (UTCM), the emerging Microsoft native answer to this problem.
Let’s get into it.
| Section 1 · What Is Configuration Drift and Why Should You Care? |
Understanding the Problem Space
Configuration drift in an Intune context can show up in a few distinct ways, and it’s worth being precise about each, that’s because the right detection method depends on what kind of drift you’re actually dealing with.
- Policy non-compliance – A device that was previously compliant is no longer meeting one or more compliance policy requirements. The device is still enrolled and receiving policies, but its state has diverged.
- Profile drift – A configuration profile is assigned and reporting as Succeeded in Intune, yet the underlying setting on the device doesn’t match the intended value. This is more common than people realise, especially with CSP-based policies.
- Remediation regression – You fixed a problem on a device. The fix didn’t stick. Common with settings that apps or Windows Update can overwrite post-boot.
- Orphaned assignments – Devices or users moved between groups, re-enrolled, or with changed licensing, leaving them with stale or missing policy assignments.
- Baseline gaps – New devices enrolled into your tenant that haven’t fully received their intended policy stack yet, often because of sync timing or group membership delays.
The Graph API Endpoints You’ll Use
The Microsoft Graph API is your primary source of truth for device and policy state in Intune. These are the endpoints you’ll come back to throughout this guide:
| Endpoint | What It Gives You |
| GET /deviceManagement/managedDevices | Core device inventory — compliance state, OS version, last sync time |
| GET /deviceManagement/managedDevices/{id}/deviceConfigurationStates | Per-device breakdown of which policies are in which state |
| GET /deviceManagement/deviceCompliancePolicies/{id}/deviceStatuses | Compliance status per policy across all targeted devices |
| GET /deviceManagement/deviceConfigurations/{id}/deviceStatuses | Configuration profile deployment states per device |
| GET /deviceManagement/deviceConfigurations?$expand=assignments | All configuration profiles with their current group assignments |
| GET /deviceManagement/deviceCompliancePolicies?$expand=assignments | All compliance policies with their current group assignments |
| GET /identity/conditionalAccess/policies | All Conditional Access policies — conditions, controls, and enabled state |
| GET /deviceManagement/configurationPolicies?$expand=assignments | Settings Catalog policies (newer framework) with assignments |
| ℹ Graph API versions Most Intune automation uses the /beta endpoint because it exposes significantly more properties than v1.0. Be aware that beta endpoints can change without notice — it’s a trade-off worth knowing about. For the Conditional Access endpoint above, use v1.0 as it is fully supported there |
The key property to monitor on each device is complianceState on the managedDevice resource. Possible values are: compliant, noncompliant, unknown, notApplicable, inGracePeriod, and error. Anything that isn’t compliant or notApplicable should be on your radar.
| Section 2 · Approach 1: PowerShell + Graph API |
The Classic Hammer
PowerShell is the natural starting point for most IT pros. It’s familiar, flexible, doesn’t require additional licensing, and you can build something genuinely useful in an afternoon. The Microsoft.Graph PowerShell SDK provides cmdlets that wrap the Graph API — use the newer Microsoft.Graph SDK, not the old AzureAD module.
What You’ll Need
| Requirement | Detail |
| PowerShell version | PowerShell 7.x recommended. Install via WinGet: winget install Microsoft.PowerShell |
| Graph SDK | Install-Module Microsoft.Graph -Scope AllUsers. For lighter installs, use individual submodules like Microsoft.Graph.DeviceManagement. |
| App Registration | Required for unattended/scheduled execution. Create one in Entra ID with appropriate API permissions. |
| API permissions | DeviceManagementManagedDevices.Read.All and DeviceManagementConfiguration.Read.All at minimum. Add ReadWrite permissions only if you need remediation actions. |
| Intune licence | Intune Plan 1 (included in M365 E3/E5, Business Premium). No additional licence needed for read-only reporting. |
A Practical Drift Detection Script
The script below connects to Graph, retrieves all managed devices, and flags those with non-compliant or unknown states. It outputs to both the console and a CSV for review:
Requires -Version 7.0
<#
.SYNOPSIS
Intune Compliance Drift Detection
move2modern.co.uk — Part 1 companion script
.DESCRIPTION
Connects interactively to Microsoft Graph, retrieves all managed devices,
flags those with non-compliant or unknown compliance states, displays
results in the console, and saves a CSV to Documents\DriftBaselines.
.NOTES
Required permissions (granted at sign-in prompt):
DeviceManagementManagedDevices.Read.All
>
SETUP
$OutputFolder = Join-Path ([Environment]::GetFolderPath(‘MyDocuments’)) ‘DriftBaselines’
$OutputFile = Join-Path $OutputFolder “DriftReport_$(Get-Date -Format ‘yyyyMMdd_HHmmss’).csv”
HELPERS
function Write-Header ($Text) {
Write-Host “”
Write-Host ” $Text” -ForegroundColor Cyan
Write-Host (” ” + (“─” * ($Text.Length))) -ForegroundColor DarkGray
}
function Write-Ok ($Msg) { Write-Host ” [OK] $Msg” -ForegroundColor Green }
function Write-Warn ($Msg) { Write-Host ” [!!] $Msg” -ForegroundColor Yellow }
function Write-Info ($Msg) { Write-Host ” $Msg” -ForegroundColor Gray }
BANNER
Write-Host “”
Write-Host ” ╔═══════════════════════════════════════════════════════╗” -ForegroundColor Cyan
Write-Host ” ║ Intune Compliance Drift Detection ║” -ForegroundColor Cyan
Write-Host ” ║ move2modern.co.uk ║” -ForegroundColor Cyan
Write-Host ” ╚═══════════════════════════════════════════════════════╝” -ForegroundColor Cyan
Write-Host “”
STEP 1 — MODULE CHECK
Write-Header “Step 1 — Checking modules”
foreach ($Mod in @(‘Microsoft.Graph.Authentication’, ‘Microsoft.Graph.DeviceManagement’)) {
if (Get-Module -ListAvailable -Name $Mod) {
Write-Ok “$Mod is installed”
} else {
Write-Warn “$Mod not found — installing…”
try {
Install-Module $Mod -Scope CurrentUser -Force -ErrorAction Stop
Write-Ok “$Mod installed successfully”
} catch {
Write-Host ” [XX] Could not install $Mod : $_” -ForegroundColor Red
Read-Host ” Press Enter to close”
exit 1
}
}
}
STEP 2 — CONNECT
Write-Header “Step 2 — Connecting to Microsoft Graph”
Write-Info “A browser sign-in window will open. Sign in with an account that has”
Write-Info “the DeviceManagementManagedDevices.Read.All permission.”
Write-Host “”
try {
Connect-MgGraph -Scopes ‘DeviceManagementManagedDevices.Read.All’ `
-NoWelcome -ErrorAction Stop
$ctx = Get-MgContext
Write-Ok “Connected as : $($ctx.Account)”
Write-Info “Tenant : $($ctx.TenantId)”
} catch {
Write-Host ” [XX] Connection failed: $_” -ForegroundColor Red
Read-Host ” Press Enter to close”
exit 1
}
STEP 3 — RETRIEVE DEVICES
Write-Header “Step 3 — Retrieving managed devices”
Write-Info “Using -All to ensure all pages are retrieved (large tenants may take a moment)…”
Write-Host “”
try {
$Devices = Get-MgDeviceManagementManagedDevice -All -Property Id, DeviceName, ComplianceState, LastSyncDateTime,
OperatingSystem, OSVersion, UserPrincipalName, EnrolledDateTime `
-ErrorAction Stop
Write-Ok “Total managed devices found: $($Devices.Count)”
} catch {
Write-Host ” [XX] Failed to retrieve devices: $_” -ForegroundColor Red
Disconnect-MgGraph
Read-Host ” Press Enter to close”
exit 1
}
STEP 4 — FILTER AND BUILD REPORT
Write-Header “Step 4 — Identifying drift”
$DriftedDevices = $Devices | Where-Object {
$_.ComplianceState -notin @(‘compliant’, ‘notApplicable’)
}
$Report = $DriftedDevices | Select-Object @(
@{ N = ‘DeviceName’; E = { $_.DeviceName } }
@{ N = ‘UPN’; E = { $_.UserPrincipalName } }
@{ N = ‘State’; E = { $_.ComplianceState } }
@{ N = ‘OS’; E = { $_.OperatingSystem } }
@{ N = ‘OSVersion’; E = { $_.OSVersion } }
@{ N = ‘LastSync’; E = { $_.LastSyncDateTime } }
@{ N = ‘DaysSinceSync’; E = { int – [datetime]$_.LastSyncDateTime).TotalDays } }
@{ N = ‘EnrolledDate’; E = { $_.EnrolledDateTime } }
)
STEP 5 — CONSOLE OUTPUT
Write-Header “Step 5 — Results”
Write-Host “”
if ($Report.Count -eq 0) {
Write-Host ” ✅ No drifted devices found. All managed devices are compliant or not applicable.” `
-ForegroundColor Green
} else {
# Summary line
Write-Host (" ⚠ {0} drifted device(s) out of {1} total ({2:0.0}%)" -f `
$Report.Count, $Devices.Count, (($Report.Count / $Devices.Count) * 100)) `
-ForegroundColor Yellow
Write-Host ""
# Column widths — dynamic, based on actual data, capped for readability
$colW = @{
DeviceName = [Math]::Min(30, ($Report | Measure-Object { $_.DeviceName.Length } -Maximum).Maximum + 2)
UPN = [Math]::Min(36, ($Report | Measure-Object { $_.UPN.Length } -Maximum).Maximum + 2)
State = 18
OS = 10
DaysSync = 12
}
# Header row
$header = " {0,-$($colW.DeviceName)}{1,-$($colW.UPN)}{2,-$($colW.State)}{3,-$($colW.OS)}{4,-$($colW.DaysSync)}" `
-f 'Device Name', 'User', 'State', 'OS', 'Days Since Sync'
$divider = " " + ("─" * ($colW.DeviceName + $colW.UPN + $colW.State + $colW.OS + $colW.DaysSync))
Write-Host $header -ForegroundColor White
Write-Host $divider -ForegroundColor DarkGray
# Data rows — colour by state
foreach ($Device in $Report | Sort-Object State, DeviceName) {
$stateColor = switch ($Device.State) {
'noncompliant' { 'Red' }
'error' { 'Red' }
'unknown' { 'Yellow' }
'inGracePeriod' { 'Cyan' }
default { 'White' }
}
$row = " {0,-$($colW.DeviceName)}{1,-$($colW.UPN)}{2,-$($colW.State)}{3,-$($colW.OS)}{4,-$($colW.DaysSync)}" `
-f `
($Device.DeviceName | Select-Object -First 1),
($Device.UPN | Select-Object -First 1),
$Device.State,
$Device.OS,
$Device.DaysSinceSync
Write-Host $row -ForegroundColor $stateColor
}
Write-Host $divider -ForegroundColor DarkGray
Write-Host ""
# State breakdown
Write-Host " Breakdown by state:" -ForegroundColor White
$Report | Group-Object State | Sort-Object Count -Descending | ForEach-Object {
Write-Host (" {0,-20} {1} device(s)" -f $_.Name, $_.Count) -ForegroundColor Gray
}
}
STEP 6 — SAVE CSV
Write-Host “”
Write-Header “Step 6 — Saving report”
Create output folder if it doesn’t exist
if (-not (Test-Path $OutputFolder)) {
New-Item -ItemType Directory -Path $OutputFolder -Force | Out-Null
Write-Ok “Created folder: $OutputFolder”
} else {
Write-Info “Output folder: $OutputFolder”
}
if ($Report.Count -eq 0) {
Write-Info “No drifted devices to save.”
} else {
try {
$Report | Export-Csv -Path $OutputFile -NoTypeInformation -Encoding UTF8 -ErrorAction Stop
Write-Ok “CSV saved to: $OutputFile”
} catch {
Write-Host ” [XX] Could not save CSV: $_” -ForegroundColor Red
}
}
DONE
Write-Host “”
Write-Host ” ─────────────────────────────────────────────────────────” -ForegroundColor DarkGray
Write-Host ” Done · move2modern.co.uk” -ForegroundColor Cyan
Write-Host ” ─────────────────────────────────────────────────────────” -ForegroundColor DarkGray
Write-Host “”
Disconnect-MgGraph
Read-Host ” Press Enter to close”
Drilling Deeper: Per-Policy States
Device-level compliance state tells you that a device has drifted, but not which policy or setting caused it. For that, query the deviceConfigurationStates endpoint per device
Requires -Version 7.0
<#
.SYNOPSIS
Intune Per-Device Policy Drift Investigation
move2modern.co.uk — Part 1 companion script
.DESCRIPTION
Run this after Get-IntuneComplianceDrift.ps1 has identified drifted devices.
Takes a Device ID (from the drift report CSV) and shows exactly which
policies are causing the problem on that specific device — including
conflict counts, which are the most common and painful real-world cause.
.HOW TO USE
Option A — use the DeviceId from the DriftReport CSV (most reliable):
.\Get-DevicePolicyDrift.ps1 -DeviceId “xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx”
Option B — use the device name as shown in Intune:
.\Get-DevicePolicyDrift.ps1 -DeviceName "DESKTOP-ABC123"
Option C — run the script with no parameters and it will prompt you.
Paste either the DeviceId or DeviceName when asked.
The script will detect which you've entered automatically.
>
param(
[string]$DeviceId,
[string]$DeviceName
)
HELPERS
function Write-Header ($Text) {
Write-Host “”
Write-Host ” $Text” -ForegroundColor Cyan
Write-Host (” ” + (“─” * ($Text.Length))) -ForegroundColor DarkGray
}
function Write-Ok ($Msg) { Write-Host ” [OK] $Msg” -ForegroundColor Green }
function Write-Warn ($Msg) { Write-Host ” [!!] $Msg” -ForegroundColor Yellow }
function Write-Info ($Msg) { Write-Host ” $Msg” -ForegroundColor Gray }
BANNER
Write-Host “”
Write-Host ” ╔═══════════════════════════════════════════════════════╗” -ForegroundColor Cyan
Write-Host ” ║ Intune Per-Device Policy Drift Investigation ║” -ForegroundColor Cyan
Write-Host ” ║ move2modern.co.uk ║” -ForegroundColor Cyan
Write-Host ” ╚═══════════════════════════════════════════════════════╝” -ForegroundColor Cyan
Write-Host “”
Write-Info “Run Get-IntuneComplianceDrift.ps1 first to identify drifted devices.”
Write-Info “Then use the Device ID from that report’s CSV to investigate here.”
STEP 1 — MODULE CHECK
Write-Header “Step 1 — Checking modules”
foreach ($Mod in @(‘Microsoft.Graph.Authentication’, ‘Microsoft.Graph.DeviceManagement’)) {
if (Get-Module -ListAvailable -Name $Mod) {
Write-Ok “$Mod is installed”
} else {
Write-Warn “$Mod not found — installing…”
try {
Install-Module $Mod -Scope CurrentUser -Force -ErrorAction Stop
Write-Ok “$Mod installed successfully”
} catch {
Write-Host ” [XX] Could not install $Mod : $_” -ForegroundColor Red
Read-Host ” Press Enter to close”
exit 1
}
}
}
STEP 2 — CONNECT
Write-Header “Step 2 — Connecting to Microsoft Graph”
try {
Connect-MgGraph -Scopes ‘DeviceManagementManagedDevices.Read.All’ `
-NoWelcome -ErrorAction Stop
$ctx = Get-MgContext
Write-Ok “Connected as : $($ctx.Account)”
Write-Info “Tenant : $($ctx.TenantId)”
} catch {
Write-Host ” [XX] Connection failed: $_” -ForegroundColor Red
Read-Host ” Press Enter to close”
exit 1
}
STEP 3 — IDENTIFY DEVICE
Write-Header “Step 3 — Device to investigate”
Write-Host “”
Write-Info “You can provide either the DeviceId (from the DriftReport CSV)”
Write-Info “or the DeviceName (exactly as it appears in Intune).”
Write-Host “”
Prompt if neither was passed as a parameter
if (-not $DeviceId -and -not $DeviceName) {
$Input = Read-Host ” Device Name or Device ID”
$Input = $Input.Trim()
# Heuristic: GUIDs are 36 chars with hyphens — treat everything else as a name
if ($Input -match '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') {
$DeviceId = $Input
} else {
$DeviceName = $Input
}
}
If we have a name but no ID, look it up via Graph API directly
(using Invoke-MgGraphRequest avoids the empty-property issue with SDK filters)
if ($DeviceName -and -not $DeviceId) {
Write-Info “Looking up ‘$DeviceName’…”
try {
$EncodedName = [Uri]::EscapeDataString($DeviceName)
$Response = Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?$filter=deviceName eq ‘$EncodedName’&$select=id,deviceName,userPrincipalName,complianceState,operatingSystem"
-Method GET -ErrorAction Stop
$Lookup = $Response.value
if ($Lookup.Count -eq 0) {
Write-Host " [XX] No device found with name '$DeviceName'." -ForegroundColor Red
Write-Info "Check the name matches exactly as shown in Intune (case-sensitive)."
Write-Info "Alternatively, use the DeviceId from the DriftReport CSV."
Disconnect-MgGraph
Read-Host " Press Enter to close"
exit 1
}
if ($Lookup.Count -gt 1) {
Write-Warn "Multiple devices found with that name — picking the first match."
Write-Info "If this is wrong, re-run using -DeviceId from the CSV instead."
}
$Found = $Lookup[0]
$DeviceId = $Found.id
$Device = [PSCustomObject]@{
DeviceName = $Found.deviceName
UserPrincipalName = $Found.userPrincipalName
ComplianceState = $Found.complianceState
OperatingSystem = $Found.operatingSystem
}
Write-Ok "Device resolved: $($Device.DeviceName) → $DeviceId"
} catch {
Write-Host " [XX] Name lookup failed: $_" -ForegroundColor Red
Disconnect-MgGraph
Read-Host " Press Enter to close"
exit 1
}
} else {
# We have an ID — fetch device details via Graph API directly
$DeviceId = $DeviceId.Trim()
try {
$Found = Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices/$DeviceId?$select=id,deviceName,userPrincipalName,complianceState,operatingSystem"
-Method GET -ErrorAction Stop
$Device = [PSCustomObject]@{
DeviceName = $Found.deviceName
UserPrincipalName = $Found.userPrincipalName
ComplianceState = $Found.complianceState
OperatingSystem = $Found.operatingSystem
}
} catch {
Write-Host " [XX] Device ID not found. Check the ID is correct and belongs to this tenant." -ForegroundColor Red
Disconnect-MgGraph
Read-Host " Press Enter to close"
exit 1
}
}
Write-Host “”
Write-Ok “Device found : $($Device.DeviceName)”
Write-Info “User : $($Device.UserPrincipalName)”
Write-Info “OS : $($Device.OperatingSystem)”
Write-Info “Overall state : $($Device.ComplianceState)”
STEP 4 — RETRIEVE POLICY STATES
Write-Header “Step 4 — Retrieving policy states”
try {
# Use Invoke-MgGraphRequest directly — the SDK cmdlet for this endpoint
# is not available in all Microsoft.Graph module configurations
$Response = Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices/$DeviceId/deviceConfigurationStates"
-Method GET -ErrorAction Stop
$AllStates = $Response.value
Write-Ok "Total policies assigned to this device: $($AllStates.Count)"
} catch {
$ErrDetail = $_.ErrorDetails.Message
if (-not $ErrDetail) { $ErrDetail = $_.Exception.Message }
Write-Host ” [XX] Failed to retrieve policy states: $ErrDetail” -ForegroundColor Red
Disconnect-MgGraph
Read-Host ” Press Enter to close”
exit 1
}
STEP 5 — DISPLAY RESULTS
Write-Header “Step 5 — Policy drift results for $($Device.DeviceName)”
Write-Host “”
$ProblemStates = $AllStates | Where-Object {
$_.state -notin @(‘compliant’, ‘notApplicable’)
}
if ($ProblemStates.Count -eq 0) {
Write-Host ” ✅ No policy-level drift found on this device.” -ForegroundColor Green
Write-Info “The device may show as non-compliant due to a compliance policy,”
Write-Info “not a configuration profile. Check the compliance policy status in Intune.”
} else {
Write-Host (" ⚠ {0} policy/policies with issues found ({1} total assigned)" -f `
$ProblemStates.Count, $AllStates.Count) -ForegroundColor Yellow
Write-Host ""
# ── Conflicts first — these are the important ones ────────────────────
$Conflicts = $ProblemStates | Where-Object { $_.conflictCount -gt 0 }
if ($Conflicts.Count -gt 0) {
Write-Host " CONFLICTS (two or more policies fighting over the same setting)" `
-ForegroundColor Red
Write-Host " ─────────────────────────────────────────────────────────────────" `
-ForegroundColor DarkGray
foreach ($Item in $Conflicts | Sort-Object conflictCount -Descending) {
Write-Host (" ❌ {0}" -f $Item.displayName) -ForegroundColor Red
Write-Host (" State: {0} Conflicts: {1} Errors: {2}" -f `
$Item.state, $Item.conflictCount, $Item.errorCount) -ForegroundColor DarkRed
}
Write-Host ""
Write-Warn "Conflicts are not fixed by a device sync — they need a policy review."
Write-Info "Go to Intune > Devices > [this device] > Configuration to see which"
Write-Info "policies overlap and which setting is being contested."
Write-Host ""
}
# ── Errors ────────────────────────────────────────────────────────────
$Errors = $ProblemStates | Where-Object { $_.state -eq 'error' -and $_.conflictCount -eq 0 }
if ($Errors.Count -gt 0) {
Write-Host " ERRORS (policy failed to apply)" -ForegroundColor Red
Write-Host " ─────────────────────────────────────────────────────────────────" `
-ForegroundColor DarkGray
foreach ($Item in $Errors) {
Write-Host (" ❌ {0}" -f $Item.displayName) -ForegroundColor Red
Write-Host (" State: {0} Errors: {1}" -f $Item.state, $Item.errorCount) `
-ForegroundColor DarkRed
}
Write-Host ""
}
# ── Non-compliant (no conflict, no error) ────────────────────────────
$NonCompliant = $ProblemStates | Where-Object {
$_.state -eq 'nonCompliant' -and $_.conflictCount -eq 0
}
if ($NonCompliant.Count -gt 0) {
Write-Host " NON-COMPLIANT (settings don't match policy requirements)" `
-ForegroundColor Yellow
Write-Host " ─────────────────────────────────────────────────────────────────" `
-ForegroundColor DarkGray
foreach ($Item in $NonCompliant) {
Write-Host (" ⚠ {0}" -f $Item.displayName) -ForegroundColor Yellow
Write-Host (" State: {0} Settings checked: {1}" -f `
$Item.state, $Item.settingCount) -ForegroundColor DarkYellow
}
Write-Host ""
}
# ── Everything else (unknown, inGracePeriod, etc.) ───────────────────
$Other = $ProblemStates | Where-Object {
$_.state -notin @('nonCompliant', 'error') -and $_.conflictCount -eq 0
}
if ($Other.Count -gt 0) {
Write-Host " OTHER STATES" -ForegroundColor Gray
Write-Host " ─────────────────────────────────────────────────────────────────" `
-ForegroundColor DarkGray
foreach ($Item in $Other) {
Write-Host (" • {0} [{1}]" -f $Item.displayName, $Item.state) -ForegroundColor Gray
}
Write-Host ""
}
}
STEP 6 — SAVE RESULTS
Write-Header “Step 6 — Saving results”
$OutputFolder = Join-Path ([Environment]::GetFolderPath(‘MyDocuments’)) ‘DriftBaselines’
$OutputFile = Join-Path $OutputFolder “PolicyDrift_$($Device.DeviceName -replace ‘[^a-zA-Z0-9]’,’‘)$(Get-Date -Format ‘yyyyMMdd_HHmmss’).csv”
if (-not (Test-Path $OutputFolder)) {
New-Item -ItemType Directory -Path $OutputFolder -Force | Out-Null
}
if ($ProblemStates.Count -gt 0) {
$ProblemStates | Select-Object `
@{ N = ‘PolicyName’; E = { $_.displayName } },
@{ N = ‘State’; E = { $_.state } },
@{ N = ‘ConflictCount’; E = { $_.conflictCount } },
@{ N = ‘ErrorCount’; E = { $_.errorCount } },
@{ N = ‘SettingCount’; E = { $_.settingCount } } |
Export-Csv -Path $OutputFile -NoTypeInformation -Encoding UTF8
Write-Ok “Results saved to: $OutputFile”
} else {
Write-Info “Nothing to save — no policy drift detected.”
}
DONE
Write-Host “”
Write-Host ” ─────────────────────────────────────────────────────────” -ForegroundColor DarkGray
Write-Host ” Done · move2modern.co.uk” -ForegroundColor Cyan
Write-Host ” ─────────────────────────────────────────────────────────” -ForegroundColor DarkGray
Write-Host “”
Disconnect-MgGraph
Read-Host ” Press Enter to close”
The ConflictCount property is particularly useful — a conflict means two or more policies are fighting over the same setting and the device is caught in the middle. This is one of the most common real-world drift causes and one of the most annoying to diagnose manually in the portal.
Pros and Cons
| ✅ Pros | ❌ Cons |
| No additional licensing required | Requires ongoing maintenance as Graph API evolves |
| Full control over logic and output format | No built-in alerting — you wire that up yourself |
| Can be run on-demand or scheduled via Azure Automation | Each run is stateless unless you build logging |
| Portable — runs from any machine with internet access | Client secret management is a security responsibility |
| Great for one-off audits and ad-hoc troubleshooting | Doesn’t scale well for real-time detection across thousands of devices |
| Easily customisable for your specific environment |
| Section 3 · Approach 2: Power Automate for Automated Workflows |
Letting the Robots Handle It
Power Automate elevates drift detection from a script you remember to run into a workflow that runs itself. But its value goes well beyond simply scheduling a compliance poll. With the HTTP connector and Graph API, you can build a complete configuration monitoring pipeline: extract your entire policy configuration as a JSON baseline, store it, compare it on a schedule, and get notified the moment something changes — covering compliance policies, configuration profiles, and Conditional Access policies in a single automated flow.
This section covers two complementary flows: a device-level compliance drift checker, and the more powerful tenant-configuration baseline comparison workflow.
What You’ll Need
| Requirement | Detail |
| Licensing | Power Automate is included with most M365 plans. The HTTP connector (needed to call Graph API directly) requires Power Automate Per User/Per Flow or M365 E3/E5. |
| Connectors | HTTP (premium), Office 365 Outlook, Microsoft Teams, SharePoint. |
| App Registration | Same as the PowerShell approach — Entra App Registration with Graph API permissions. Credentials stored as encrypted values in flow actions. |
| SharePoint Site | A SharePoint document library to store baseline JSON files and a SharePoint List for drift log entries. |
A Scheduled Drift Detection Flow
Here’s the logical structure for a scheduled drift checker. The key stages are:
- Trigger: Recurrence — daily at 06:00 UTC (before the helpdesk opens)
- Action: HTTP POST to https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token to get a bearer token
- Action: HTTP GET to the Graph endpoint below, server-side filtered to non-compliant devices only
- Condition: if the array is empty, send an ‘all clear’ notification and exit; if not, proceed
- Apply to Each: loop through drifted devices, create a SharePoint list item for each with device name, state, last sync, and timestamp
- Action: Post Teams Adaptive Card to your IT ops channel with a count summary and list of affected devices
- Condition: if drift count exceeds a threshold (e.g. 10), send an escalation email to your IT manager
The OData filter in step 3 does the heavy lifting — rather than downloading all devices and filtering locally, we ask Graph to do it server-side:
| // URI for HTTP GET action — filters non-compliant devices server-side // URI-encode the filter string in production https://graph.microsoft.com/v1.0/deviceManagement/managedDevices ?$filter=complianceState ne ‘compliant’ and complianceState ne ‘notApplicable’ &$select=id,deviceName,complianceState,lastSyncDateTime,userPrincipalName,operatingSystem,osVersion &$orderby=lastSyncDateTime asc &$top=999 |
| ⚠ OData filter support is inconsistent Not all Intune Graph endpoint properties support OData filtering. The complianceState property does. If you get a 400 Bad Request, try removing the filter and doing client-side filtering — it’s slower but reliable. |
Flow B: Tenant Configuration Baseline Capture
The flow makes three Graph API calls to capture the full configuration picture: compliance policies with their assignment groups, configuration profiles with assignments, and Conditional Access policies. Each response is serialised to JSON and stored as a versioned file in SharePoint:
- Trigger: Manual trigger (Button) — run this deliberately when you want to establish or update a baseline
- Action: HTTP GET to fetch all compliance policies with assignments:
| // Step 2a: Compliance policies with assignments |
| https://graph.microsoft.com/beta/deviceManagement/deviceCompliancePolicies |
| ?$expand=assignments |
| &$select=id,displayName,lastModifiedDateTime,assignments |
| // Step 2b: Configuration profiles with assignments |
| https://graph.microsoft.com/beta/deviceManagement/deviceConfigurations |
| ?$expand=assignments |
| &$select=id,displayName,lastModifiedDateTime,assignments |
| // Step 2c: Settings Catalog policies (newer framework) |
| https://graph.microsoft.com/beta/deviceManagement/configurationPolicies |
| ?$expand=assignments |
| &$select=id,name,lastModifiedDateTime,assignments |
| // Step 2d: Conditional Access policies |
| https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies |
| ?$select=id,displayName,state,conditions,grantControls,sessionControls,modifiedDateTime |
- Action: Compose to build a single JSON object combining all four responses with a capturedAt timestamp:
| // Compose action expression to build baseline JSON |
| { |
| “capturedAt”: “@{utcNow()}”, |
| “capturedBy”: “@{workflow().run.name}”, |
| “compliancePolicies”: @{body(‘Get_Compliance_Policies’)[‘value’]}, |
| “configProfiles”: @{body(‘Get_Config_Profiles’)[‘value’]}, |
| “catalogPolicies”: @{body(‘Get_Catalog_Policies’)[‘value’]}, |
| “conditionalAccessPolicies”: @{body(‘Get_CA_Policies’)[‘value’]} |
| } |
- Action: SharePoint Create File — save to your baselines document library as baseline_YYYYMMDD_HHMMSS.json. Use the dynamic timestamp in the filename to version each capture.
- Action: Post Teams message confirming the baseline was captured, with counts of each policy type included.
Flow C: Scheduled Baseline Comparison (Drift Detection)
This is where the value compounds. This scheduled flow runs daily, captures a fresh snapshot of your current tenant configuration, then compares it against your stored baseline JSON. Any differences — a deleted profile, a changed CA policy, a modified assignment group — surface as drift records:
- Trigger: Recurrence — daily at 07:00 UTC
- Actions: Same three HTTP GET calls as Flow B to capture current state
- Action: SharePoint Get File Content — retrieve your most recent baseline JSON from the SharePoint library
- Action: Parse JSON on both the baseline and current snapshot using the same schema
- Apply to Each (compliance policies): compare each policy in the baseline against current. Flag if: policy is missing from current, assignment groups have changed, or lastModifiedDateTime has changed since baseline
- Apply to Each (CA policies): compare each CA policy. Flag if: policy was deleted, state changed (enabled → disabled or vice versa), conditions changed, or grant controls modified
- Condition: if any drift items were found, create a SharePoint list entry per drift item and post a Teams Adaptive Card detailing what changed
- Condition: if CA policy drift was detected, send a priority email — CA changes have immediate security implications
| // Expression to detect assignment changes between baseline and current |
| // Use in a Condition action comparing the two JSON objects |
| // In Compose – extract group IDs from baseline policy assignments: |
| join(baseline_policy[‘assignments’], ‘,’) |
| // Compare with current (not-equals condition triggers drift flag): |
| join(current_policy[‘assignments’], ‘,’) |
| // For CA policies – check if state changed: |
| @equals(baseline_ca_policy[‘state’], current_ca_policy[‘state’]) |
| // For modification timestamp change (any property may have changed): |
| @not(equals(baseline_policy[‘lastModifiedDateTime’], current_policy[‘lastModifiedDateTime’])) |
| 💡What this catches that device polling misses Flow A tells you which devices have drifted from their policies. Flows B and C tell you whether your policies themselves have changed. These are different problems. A tenant where a Conditional Access policy has been silently disabled will show all devices as compliant while being in a significantly worse security posture. BUT both levels of monitoring matter. |
Adding Automated Remediation Triggers
Power Automate can also initiate remediation — not just detect drift. The most straightforward action is triggering a device sync for devices showing compliance drift:
| // Force sync on a specific managed device // POST https://graph.microsoft.com/v1.0/deviceManagement/managedDevices/{id}/syncDevice // In Power Automate HTTP action: // Method: POST // URI: https://graph.microsoft.com/v1.0/deviceManagement/managedDevices/@{items(‘Apply_to_each’)?[‘id’]}/syncDevice // Headers: Authorization: Bearer @{body(‘Get_Token’)?[‘access_token’]} // Body: {} (empty JSON object required) |
| ℹ Sync vs. fix Triggering a sync tells the device to check in and re-apply pending policies. It doesn’t fix drift caused by a policy conflict — if two policies are fighting over the same setting, the sync will just confirm the problem. Knowing the difference matters before you automate anything. |
Pros and Cons
| ✅ Pros | ❌ Cons |
| No infrastructure to manage — fully cloud-hosted | HTTP connector requires premium licensing |
| Built-in scheduling, retry logic, and run history | Flow execution limits can bite in large tenants |
| Native integration with Teams, SharePoint, Outlook | Complex logic is harder to version-control vs. scripts |
| Low-code and accessible to admins without deep scripting | Debugging failed flows can be a painful experience |
| Can create automatic audit trails in SharePoint | Service account/credential management is buried in flow config |
| Adaptive Cards in Teams give rich, actionable notifications | Limited ability to do complex data transformations without expressions |
| Section 4 · Approach 3: PowerShell + Azure Automation (Best of Both) |
Best of Both Worlds
PowerShell is great at data collection and complex logic. Power Automate is great at scheduling, notifications, and workflow orchestration. They’re not in competition — they’re complements. Combining PowerShell runbooks in Azure Automation with Power Automate as the orchestration layer gives you raw capability plus operational convenience.
The Architecture
- Azure Automation Account — hosts PowerShell runbooks that do the heavy lifting: Graph API queries, complex filtering, baseline comparisons, CSV generation. Managed Identity support means no client secret to rotate.
- Power Automate — acts as the orchestrator and notification layer. Triggers the Automation runbook on a schedule, waits for completion, routes alerts based on results.
- Azure Storage Account (optional) — Automation runbooks write CSV reports to blob storage; the Power Automate flow generates a SAS URL and includes it in the Teams notification for easy download.
Granting Graph Permissions to Azure Automation
The step that trips most people up: you can’t toggle Graph permissions for a managed identity in the Entra portal. You have to use PowerShell:
| # Run once to grant Graph permissions to your Automation Account Managed Identity # Replace $automationMIObjectId with the Object ID from the Automation Account Identity blade Connect-MgGraph -Scopes ‘AppRoleAssignment.ReadWrite.All’,’Application.Read.All’ $automationMIObjectId = ‘<Your-Automation-Account-MI-Object-ID>’ $graphSPN = Get-MgServicePrincipal -Filter “AppId eq ‘00000003-0000-0000-c000-000000000000′” $permissions = @( ‘DeviceManagementManagedDevices.Read.All’, ‘DeviceManagementConfiguration.Read.All’ ) foreach ($perm in $permissions) { $appRole = $graphSPN.AppRoles | Where-Object { $_.Value -eq $perm -and $_.AllowedMemberTypes -contains ‘Application’ } New-MgServicePrincipalAppRoleAssignment ` -ServicePrincipalId $automationMIObjectId ` -PrincipalId $automationMIObjectId ` -ResourceId $graphSPN.Id ` -AppRoleId $appRole.Id Write-Host “Granted: $perm” } |
| ℹ Well-known Graph App ID The AppId ‘00000003-0000-0000-c000-000000000000’ is the fixed global identifier for Microsoft Graph in every tenant. This never changes. Using Get-MgServicePrincipal with this filter is the reliable way to find the Graph service principal regardless of your tenant setup. |
Pros and Cons
| ✅ Pros | ❌ Cons |
| Managed Identity removes client secret headaches | More moving parts — more things that can break |
| Azure Automation provides proper runbook versioning | Requires an Azure subscription in addition to M365 |
| Power Automate handles notifications without code | Debugging cross-service failures means checking logs in multiple places |
| Runbook output persisted to Storage for audit/trending | Azure Automation module management (keeping SDK current) is manual |
| Scales well — add more runbooks for different drift scenarios | Initial setup time is significantly higher than pure PowerShell |
| Azure Automation free tier (500 min/month) covers most small/medium tenants | Power Automate premium connector still required for HTTP actions |
| Section 5 · Approach 4: A Self-Built Configuration Governance Engine |
Full Control Over What You Monitor
This approach requires real development effort, not just scripting. But for organisations that want policy-level drift detection — ‘has anyone changed an assignment?’ or ‘has a compliance policy setting been modified?’ or ‘was a Conditional Access policy disabled?’ — building a lightweight baseline comparison engine against the Graph API is entirely achievable. It gives you complete control which is different to UTCM, it works today without additional licensing, and covers exactly the configuration dimensions that matter to your organisation.
Three Components
- Baseline Snapshot — a scheduled process that calls Graph API to capture intended state: all policy assignments, configuration profiles, compliance policy requirements. Stored as JSON in Azure Table Storage or similar.
- Current State Collector — runs on a schedule to capture actual policy state from your tenant.
- Diff Engine — compares snapshot against current state and generates a delta report: what changed, what is missing, what is in conflict.
Capturing a Baseline Snapshot
The snapshot should cover the full breadth of what you care about. The example below captures configuration profiles, compliance policies, Settings Catalog policies, and Conditional Access policies in a single pass:
| # Capture a comprehensive configuration baseline # Uses beta endpoint for richer detail Connect-MgGraph -Scopes ‘DeviceManagementConfiguration.Read.All’,’Policy.Read.ConditionalAccess’ -NoWelcome $configProfiles = Invoke-MgGraphRequest ` -Uri ‘https://graph.microsoft.com/beta/deviceManagement/deviceConfigurations?$expand=assignments’ ` -Method GET $compliancePolicies = Invoke-MgGraphRequest ` -Uri ‘https://graph.microsoft.com/beta/deviceManagement/deviceCompliancePolicies?$expand=assignments’ ` -Method GET $catalogPolicies = Invoke-MgGraphRequest ` -Uri ‘https://graph.microsoft.com/beta/deviceManagement/configurationPolicies?$expand=assignments’ ` -Method GET $caPolicies = Invoke-MgGraphRequest ` -Uri ‘https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies’ ` -Method GET $snapshot = @{ CapturedAt = (Get-Date -Format ‘o’) CapturedBy = $env:USERNAME ConfigProfiles = $configProfiles.value CompliancePolicies = $compliancePolicies.value CatalogPolicies = $catalogPolicies.value CAPolicies = $caPolicies.value } $filename = “baseline_$(Get-Date -Format yyyyMMdd_HHmmss).json” $snapshot | ConvertTo-Json -Depth 20 | Out-File -FilePath “.\baselines\$filename” -Encoding UTF8 Write-Host “Baseline captured: $filename” |
Comparing Baselines
The comparison function checks three types of drift: deleted resources, changed assignments, and modified policy settings (detected via the lastModifiedDateTime timestamp). Conditional Access policies additionally check for state changes (enabled/disabled):
| function Compare-ConfigurationBaselines { param([string]$BaselinePath, [string]$CurrentPath) $baseline = Get-Content $BaselinePath | ConvertFrom-Json $current = Get-Content $CurrentPath | ConvertFrom-Json $driftReport = @() # — Configuration profiles — foreach ($bItem in $baseline.ConfigProfiles) { $cItem = $current.ConfigProfiles | Where-Object { $_.id -eq $bItem.id } if (-not $cItem) { $driftReport += [PSCustomObject]@{ Type=’ConfigProfile’; Name=$bItem.displayName DriftType=’DELETED’; Detail=’Profile no longer exists in tenant’ } continue } $bGroups = ($bItem.assignments.target | Sort-Object groupId).groupId -join ‘,’ $cGroups = ($cItem.assignments.target | Sort-Object groupId).groupId -join ‘,’ if ($bGroups -ne $cGroups) { $driftReport += [PSCustomObject]@{ Type=’ConfigProfile’; Name=$bItem.displayName DriftType=’ASSIGNMENT_CHANGED’; Detail=”Was: $bGroups | Now: $cGroups” } } if ($bItem.lastModifiedDateTime -ne $cItem.lastModifiedDateTime) { $driftReport += [PSCustomObject]@{ Type=’ConfigProfile’; Name=$bItem.displayName DriftType=’SETTINGS_MODIFIED’; Detail=”Modified: $($cItem.lastModifiedDateTime)” } } } # — Conditional Access policies (high-value target) — foreach ($bCA in $baseline.CAPolicies) { $cCA = $current.CAPolicies | Where-Object { $_.id -eq $bCA.id } if (-not $cCA) { $driftReport += [PSCustomObject]@{ Type=’ConditionalAccess’; Name=$bCA.displayName DriftType=’DELETED’; Detail=’CA policy no longer exists – INVESTIGATE IMMEDIATELY’ } continue } if ($bCA.state -ne $cCA.state) { $driftReport += [PSCustomObject]@{ Type=’ConditionalAccess’; Name=$bCA.displayName DriftType=’STATE_CHANGED’; Detail=”Was: $($bCA.state) | Now: $($cCA.state)” } } if ($bCA.modifiedDateTime -ne $cCA.modifiedDateTime) { $driftReport += [PSCustomObject]@{ Type=’ConditionalAccess’; Name=$bCA.displayName DriftType=’CONDITIONS_MODIFIED’; Detail=”Modified: $($cCA.modifiedDateTime)” } } } return $driftReport |
| 📖This approach is tenant-configuration level, not device level This comparison engine detects changes to your Intune policies and assignments — not whether individual devices have drifted from those policies. It answers: ‘Has something changed in how my tenant is configured?’ rather than ‘Is this specific device compliant?’ Both questions matter. They need different tools. Use this alongside, not instead of, the device-level approaches in Sections 2 and 3. |
Pros and Cons
| ✅ Pros | ❌ Cons |
| Maximum flexibility — compare exactly what matters to your org | Significant development and maintenance investment |
| Can detect policy-level drift (assignments changed, policies deleted) | You own the bugs — and there will be bugs |
| CA policy monitoring included — critical for security posture | Graph API beta endpoint can change, breaking comparisons silently |
| Persistent baseline enables historical trending | Baseline management requires process discipline (who owns the golden baseline?) |
| No additional licensing beyond existing Graph API access | No UI unless you build one |
| Fully customisable alerting and output | Harder to onboard other admins to a home-grown tool |
| Section 6 · Approach 5: Intune’s Built-In Remediations |
The Underused Feature
Before going further down the custom-build path, it’s worth spotlighting a native Intune feature that many IT pros overlook: Remediations (previously called Proactive Remediations before the 2023 rename). This is Intune’s built-in mechanism for detect-and-fix scripting, and it’s genuinely excellent for specific drift scenarios.
Remediations work by deploying two PowerShell scripts to devices. The detection script exits with code 0 if everything is fine, or code 1 if a problem is detected. The remediation script runs only if the detection exits with code 1, makes the fix, and exits 0 on success. This is the closest thing to real-time, device-level drift correction that Intune provides natively.
A Practical Example — Windows Firewall
| # Detection Script — checks if Windows Firewall Domain Profile is enabled # Exit 0 = compliant (no remediation needed) # Exit 1 = non-compliant (trigger remediation) try { $fwProfile = Get-NetFirewallProfile -Profile Domain -ErrorAction Stop if ($fwProfile.Enabled -eq $true) { Write-Host ‘Domain firewall profile is enabled. Compliant.’ exit 0 } else { Write-Host ‘Domain firewall profile is DISABLED. Non-compliant.’ exit 1 } } catch { Write-Host “Error checking firewall: $($_.Exception.Message)” exit 1 } # ──────────────────────────────────────────────────── # Remediation Script — re-enables Windows Firewall Domain Profile try { Set-NetFirewallProfile -Profile Domain -Enabled True -ErrorAction Stop Write-Host ‘Domain firewall profile re-enabled successfully.’ exit 0 } catch { Write-Host “Failed to enable firewall: $($_.Exception.Message)” exit 1 } |
Licence Requirements
| ⚠ Licensing required Remediations require Windows 10/11 devices with one of: Microsoft Intune Plan 1 with Windows Enterprise E3/E5, Microsoft 365 Business Premium, or Microsoft 365 F3. NOT available with standard Intune Plan 1 alone on Windows Pro. Check your licence before building scripts that depend on this feature. |
| Detail | Value |
| Execution context | SYSTEM by default. Can be configured to run as the logged-in user. SYSTEM context is needed for most remediation actions. |
| Execution frequency | Configurable: once, daily, or hourly. Hourly detection is the closest thing to real-time drift correction in native Intune. |
| Script signing | Not required for Intune Remediations. But test thoroughly — a bad remediation script running as SYSTEM on thousands of devices is a bad day. |
| Platform coverage | Windows 10/11 only. No macOS, iOS, or Android support. |
Pros and Cons
| ✅ Pros | ❌ Cons |
| Native Intune feature — no external infrastructure needed | Requires Windows E3/E5 or M365 Business Premium licence |
| Can run up to every hour for near-real-time correction | Only works for settings detectable and fixable via PowerShell |
| Results viewable directly in Intune portal per device | Each scenario is a separate script pair — no centralised logic |
| SYSTEM context enables powerful remediation | Not suitable for detecting policy/assignment-level drift in the tenant |
| Great for specific, known drift scenarios with deterministic fixes | Running SYSTEM-context scripts at scale requires rigorous testing |
| Scripts versioned and deployed like any Intune policy | Windows only — no cross-platform support |
| Section 7 · Approach 6.FREE Community tools |
When to exhaust all the above options there is naturally one more route you could take: The Community tools path – The Intune community has been active producing great tools for some time including helpful and automated ways to achieve ways of backup, restore and with some, reporting for drift. While these options are will save you a bunch of time an effort they wont help you learn the under the hood fundamentals for how drift management works. You can check the following tools out.
Microsoft 365 DSC
The heavyweight in this space. It’s an open-source initiative hosted on GitHub, led by Microsoft engineers and maintained by the community, designed to automate change management by maintaining a single declarative configuration file across all Microsoft 365 workloads. (https://blog.admindroid.com/automate-microsoft-365-settings-with-microsoft365dsc/#How-to-Install-Microsoft365DSC-Module%3F)
IntuneCD (Tobias Almén — Microsoft MVP)
The most sophisticated GitOps approach. IntuneCD is a Python package designed to back up Intune configurations to a Git repository from a DEV environment and automatically detect any alterations and propagate approved changes to the PROD Intune environment. (https://github.com/almenscorner/IntuneCD)
IntuneAutomate (PowerApps & Power Automate)
IntuneAutomate – Developed by myself, Andy Jones this FREE community tool looks to maximise the use of peoples existing Microsoft investments and uses a Power App and Power Automate solution which includes drift management as one of the key tasks. This tool is soon to be released to the community and you can pre-register for the tool here (https://kumonix.com/intuneautomate-power-app).
TenuVault
Ugur Koc’s Drift-Relevant Tool TenuVault is his most directly relevant tool here. It continuously monitors your Intune environment for unauthorized or unexpected changes, using a backup-as-baseline model GitHub: ugurkocde/TenuVault
IntuneBackupAndRestore (John Seerden)
A PowerShell module that queries Microsoft Graph and allows for cross-tenant backup and restore of Intune configuration, storing everything as JSON files. It includes Compare-IntuneBackupDirectories to compare two backup sets and identify what changed between them. GitHub: jseerden/IntuneBackupAndRestore
| Section 8 · Choosing the Right Approach |
Decision Framework
None of the approaches here are mutually exclusive. In fact, a mature drift management strategy typically combines several. But if you need a starting point, here’s a practical approach you may want to take:
| Scenario | Best Approach | Effort | Notes |
| One-off compliance audit | PowerShell + Graph API | Low | Run the script, export the CSV. Perfect for quarterly reviews. |
| Ongoing device-state alerting without extra Azure cost | Power Automate (Flow A) | Medium | Premium connector required, but no Azure subscription needed. |
| Tenant config change detection (CA policies, profile assignments) | Power Automate (Flows B+C) or DIY Baseline Engine | Medium–High | Both approaches detect policy-level changes. Power Automate is lower infrastructure; DIY is more flexible. |
| Auto-fix known device-level drift | Intune Remediations | Medium | Requires E3/E5 or Business Premium. Excellent ROI for common issues. |
| Full detection-to-ticket pipeline | Azure Automation + Power Automate | High | Best for mature IT ops with ITSM integration. |
| Prefer a maintained community tool with minimal build effort | TenuVault or IntuneCD | Low–Medium | TenuVault is the quickest to get running. IntuneCD is best for GitOps-oriented teams. |
| Broadest M365 coverage + auto-remediation, free | Microsoft 365 DSC | High | Most powerful free option. Significant initial setup investment. |
Security Considerations Across All Approaches
- Least privilege: App registrations and managed identities should have only the Graph API permissions they need. Read-only reporting needs DeviceManagementManagedDevices.Read.All. Add ReadWrite permissions only for remediation actions — and document the justification.
- Secret rotation: If using client secrets (not recommended for production), set calendar reminders for rotation before expiry. Drift detection pipelines that go silent because a secret expired are an embarrassingly common issue.
- Managed Identity first: Where Azure Automation is involved, use system-assigned or user-assigned managed identities instead of client credentials. No secret to manage, no secret to leak.
- Audit everything: Enable Entra ID audit logging for your app registrations. All Graph API calls made under your app’s credentials are logged — when something unexpected happens, you want that trail.
| Section — · Closing Thoughts |
Configuration drift is one of those problems that feels manageable right up until it isn’t. The good news is that Intune provides a genuinely rich set of APIs for monitoring and remediating device state, and the tooling available to IT pros — PowerShell, Power Automate, Azure Automation, Remediations, and a well-developed free community ecosystem — gives you multiple credible paths to getting drift under control without a significant licensing conversation.
The honest picture is that all of the approaches here require you to do the work, own the maintenance, and stay on top of API changes. That’s not a knock on any of the tools — it’s the reality of building your own management plane. The Power Automate baseline comparison flows and the DIY snapshot engine in particular are genuinely capable at detecting tenant configuration drift, including the high-value scenarios like Conditional Access policy changes. They’re not workarounds; for many organisations they’re exactly the right tool.
Which brings us neatly to the second blog in the series – Part 2.
Microsoft has built UTCM or Unified Tenant Configuration Management service (Currently in public preview -March 2026). This will be Microsoft’s native answer to exactly this problem. In Part 2, we’ll look at what UTCM actually does, how it compares to everything here, the real-world licensing picture, and importantly: what UTCM can actually monitor today vs. what the documentation suggests it can monitor (in my own testing experience in March 2026). Those two lists are not the same — and understanding the difference is the difference between a smooth setup and an afternoon of confusing errors.
Make sure to look out for the next blog post…….
Technical References
Microsoft Graph API — Intune Overview — https://learn.microsoft.com/en-us/graph/api/resources/intune-graph-overview
Microsoft.Graph PowerShell SDK — https://learn.microsoft.com/en-us/powershell/microsoftgraph/overview
Intune Managed Device resource (Graph API) — https://learn.microsoft.com/en-us/graph/api/resources/intune-devices-manageddevice
deviceConfigurationState resource type — https://learn.microsoft.com/en-us/graph/api/resources/intune-deviceconfig-deviceconfigurationstate
Remediations in Intune — https://learn.microsoft.com/en-us/mem/intune/fundamentals/remediations
Azure Automation Overview — https://learn.microsoft.com/en-us/azure/automation/overview
Power Automate HTTP Connector — https://learn.microsoft.com/en-us/connectors/webcontents/
OData query parameters in Graph API — https://learn.microsoft.com/en-us/graph/query-parameters
Managed Identities for Azure Automation — https://learn.microsoft.com/en-us/azure/automation/automation-security-overview