Practical engineering guidance
Why is this device non-compliant? Intune compliance triage at scale
The Intune portal tells you a device is non-compliant. It takes several clicks to learn which policy, and there is no clicking your way through nine hundred devices. A script that answers it in one pass.
Series: Intune and Configuration Manager
Conditional Access requires a compliant device. A device is not compliant. The user cannot work, and the portal shows a red cross next to a policy name without saying which setting inside that policy failed, or when it last tried.
For one device this is a few clicks. For the ninety that appeared after a policy change last night, it is a morning. The information is all in Microsoft Graph, and it takes one pass to collect.
The problem: “non-compliant” is six different states
Intune records a compliance state per device, and the states do not mean what their names suggest:
| State | What it actually means |
|---|---|
| Compliant | The device applied the assigned policies and met them |
| NonCompliant | It failed at least one setting, or the user did not act |
| InGracePeriod | It failed, but the policy grants time before enforcement bites |
| Error | A setting could not be evaluated |
| Unknown / not evaluated | Usually a device that has never checked in, or has no policy assigned |
| NotApplicable | The policy does not apply to this platform |
Where several policies target a device, the most severe state wins: Error outranks NonCompliant, which outranks InGracePeriod, which outranks Compliant.
Two documented behaviours cause most of the confusion:
Error is sticky for seven days. When a setting evaluates to Error, the device keeps its previous compliance state for up to seven days. After that it becomes non-compliant — or in-grace-period if the policy has a grace period. A device that “suddenly” went non-compliant may have failed a week ago.
Grace period is not leniency, it is a timer. InGracePeriod means the device has already
failed and the expiry date is in the future. Once it passes, access goes.
The tenant setting that changes every answer
Before triaging individual devices, check one tenant-wide setting: mark devices with no compliance policy assigned as. It defaults to Compliant, meaning a device targeted by no policy at all is treated as compliant — and therefore satisfies a Conditional Access policy that requires compliance, having been evaluated against nothing.
Microsoft’s own recommendation, where Conditional Access depends on compliance, is to set this to Not compliant. Do that deliberately and with warning, because flipping it can make a large number of devices non-compliant at once.
The neighbouring setting matters too: compliance status validity period, default 30 days. A device that has not reported within the window is treated as non-compliant regardless of what it last reported.
The script
Read-only by default. With -SyncStaleDevices it will also ask stale devices to check in,
which is the one remediation that is both safe and frequently sufficient.
<#
.SYNOPSIS
Reports Intune devices that are not compliant, with the policies they failed.
.DESCRIPTION
Read-only by default. Lists managed devices whose compliance state is not Compliant,
and for each one retrieves the per-policy compliance state so the failing policy is
named rather than guessed.
Requires an existing Microsoft Graph connection. For the read-only report:
Connect-MgGraph -Scopes 'DeviceManagementManagedDevices.Read.All'
-SyncStaleDevices additionally requires DeviceManagementManagedDevices.PrivilegedOperations.All,
which is the only permission Microsoft documents for the sync action. It is a privileged
scope covering other high-impact device actions, so consent to it deliberately and
separately rather than adding it to a standing connection.
.PARAMETER ReportPath
CSV output path. The folder must exist.
.PARAMETER StaleDays
A device that has not checked in within this many days is reported as stale.
Default 7. Compare with the tenant's compliance status validity period (default 30).
.PARAMETER IncludeUnknown
Also report devices in Unknown/not-evaluated state. These are usually inactive
devices rather than a compliance problem, so they are excluded by default.
.PARAMETER SyncStaleDevices
Ask each stale device to check in. This is a request, not a guarantee: the device
acts when it is online. Supports -WhatIf.
.EXAMPLE
.\Get-IntuneComplianceFailure.ps1 -ReportPath C:\Reports\compliance.csv -Verbose
.EXAMPLE
.\Get-IntuneComplianceFailure.ps1 -SyncStaleDevices -WhatIf
.NOTES
Read-only unless -SyncStaleDevices is supplied.
Output contains device names and user principal names. Treat as sensitive.
Per-setting failure detail is not retrieved: Microsoft documents the setting states as a
nested property of the policy state, not as a queryable collection.
#>
#requires -Version 7.0
#requires -Modules Microsoft.Graph.DeviceManagement
[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
param(
[ValidateScript({ Test-Path -Path (Split-Path -Path $_ -Parent) -PathType Container })]
[string]$ReportPath = (Join-Path $env:TEMP 'intune-compliance.csv'),
[ValidateRange(1, 120)]
[int]$StaleDays = 7,
[switch]$IncludeUnknown,
[switch]$SyncStaleDevices
)
$ErrorActionPreference = 'Stop'
if ($null -eq (Get-MgContext)) {
throw "Not connected. Run: Connect-MgGraph -Scopes 'DeviceManagementManagedDevices.Read.All'"
}
$interesting = @('noncompliant', 'error', 'inGracePeriod', 'conflict')
if ($IncludeUnknown) { $interesting += @('unknown', 'notEvaluated') }
Write-Verbose 'Reading managed devices.'
$devices = Get-MgDeviceManagementManagedDevice -All -ErrorAction Stop
Write-Verbose "$($devices.Count) managed device(s) returned."
$staleBefore = (Get-Date).AddDays(-$StaleDays)
$findings = [System.Collections.Generic.List[object]]::new()
$syncFailures = [System.Collections.Generic.List[object]]::new()
foreach ($device in $devices) {
if ($device.ComplianceState -notin $interesting) { continue }
# Name the failing policies rather than reporting "non-compliant" and stopping there.
$failedPolicies = try {
$states = Get-MgDeviceManagementManagedDeviceCompliancePolicyState -ManagedDeviceId $device.Id -All -ErrorAction Stop
@($states | Where-Object { $_.State -notin @('compliant', 'notApplicable', 'unknown') } |
ForEach-Object { "$($_.DisplayName) [$($_.State)]" }) -join '; '
}
catch {
Write-Warning "Policy state unavailable for $($device.DeviceName): $($_.Exception.Message)"
'lookup failed'
}
$lastSync = $device.LastSyncDateTime
$isStale = $lastSync -and ([datetime]$lastSync -lt $staleBefore)
$findings.Add([pscustomobject]@{
DeviceName = $device.DeviceName
DeviceId = $device.Id
User = $device.UserPrincipalName
OperatingSystem = "$($device.OperatingSystem) $($device.OsVersion)"
Ownership = $device.ManagedDeviceOwnerType
ComplianceState = $device.ComplianceState
GraceExpiresUtc = if ($device.ComplianceGracePeriodExpirationDateTime) {
([datetime]$device.ComplianceGracePeriodExpirationDateTime).ToUniversalTime().ToString('yyyy-MM-dd')
}
else { '' }
LastSyncUtc = if ($lastSync) { ([datetime]$lastSync).ToUniversalTime().ToString('yyyy-MM-dd HH:mm') } else { 'never' }
Stale = $isStale
FailedPolicies = if ([string]::IsNullOrWhiteSpace($failedPolicies)) { 'none reported' } else { $failedPolicies }
})
}
$findings | Sort-Object ComplianceState, DeviceName | Export-Csv -Path $ReportPath -NoTypeInformation -Encoding UTF8
Write-Verbose "Wrote $ReportPath"
$findings | Group-Object ComplianceState | Sort-Object Count -Descending | ForEach-Object {
Write-Warning "$($_.Name): $($_.Count) device(s)"
}
$stale = @($findings | Where-Object Stale)
if ($stale.Count -gt 0) {
Write-Warning "$($stale.Count) device(s) have not checked in for $StaleDays days. Their compliance state reflects the last report, not the device now."
}
if ($SyncStaleDevices -and $stale.Count -gt 0) {
foreach ($device in $stale) {
if ($PSCmdlet.ShouldProcess($device.DeviceName, 'Request device check-in (Intune sync)')) {
try {
Sync-MgDeviceManagementManagedDevice -ManagedDeviceId $device.DeviceId -ErrorAction Stop
Write-Verbose "Sync requested: $($device.DeviceName)"
}
catch {
$syncFailures.Add([pscustomobject]@{ Device = $device.DeviceName; Reason = $_.Exception.Message })
Write-Warning "Sync request failed for $($device.DeviceName): $($_.Exception.Message)"
}
}
}
if ($syncFailures.Count -gt 0) {
Write-Warning "$($syncFailures.Count) sync request(s) failed. A missing DeviceManagementManagedDevices.PrivilegedOperations.All scope is the usual cause."
}
}
$findings | Sort-Object ComplianceState, DeviceName
Working the output
Group by FailedPolicies before looking at devices. Compliance failures cluster: one policy
name against two hundred devices is a policy problem, and no amount of per-device work will fix
it. One device failing three unrelated policies is a device problem.
Then separate by Stale. A device that has not checked in has not failed — it has not
reported. Its compliance state is a historical record. Sync it, wait, and look again before
treating it as a fault.
FailedPolicies reading none reported with a non-compliant state is its own signal: the
device is non-compliant for a reason not attributable to a policy state, which commonly means
the tenant-wide “no policy assigned” setting has classified it, or the validity period expired.
What the script deliberately does not do
It does not retrieve per-setting failure detail. The compliance policy state object carries a nested collection of setting states, but Microsoft does not document a query for expanding it on this endpoint, and the standalone per-setting resource is documented as having no relationships of its own. Rather than ship a query I cannot point at documentation for, the script stops at policy level, which is where the actionable grouping happens anyway. The documented route to per-setting data is the tenant-wide setting-state summary, which answers “which setting is failing across the estate” rather than “why is this device failing”.
It also does not retire, wipe or remove devices. Sync is the only action here, it is
reversible in the sense that it changes nothing by itself, and even that is behind -WhatIf.
For the tenant-wide configuration rather than per-device state, ConfigReview, my read-only assessment project, checks the setting for devices without a compliance policy and whether every platform with enrolled devices has one assigned.
Verification and limits
The managed device and per-device compliance policy state endpoints, the cmdlets
Get-MgDeviceManagementManagedDevice, Get-MgDeviceManagementManagedDeviceCompliancePolicyState
and Sync-MgDeviceManagementManagedDevice, the least-privilege read scope
DeviceManagementManagedDevices.Read.All, the privileged scope required by the sync action,
the compliance state severity ordering, the seven-day Error behaviour, the grace period
semantics, and the two tenant-wide compliance policy settings and their defaults were checked
against current Microsoft documentation on 20 September 2026.
The script was written for this article and statically analysed; it was not run against a
production tenant. Reading per-policy state costs one Graph call per non-compliant device, so a
tenant with thousands of them will take time and may be throttled — run it out of hours, and
narrow the set before using -SyncStaleDevices.
Microsoft documents no specific throttling limit or completion time for the sync action, so neither this article nor the script promises one. The request is queued; the device acts when it next has connectivity.
References
Reader feedback
Was this article useful?
No ratings yet. Be the first to rate this article.
