What This Script Does
Stale device records are one of the most persistent hygiene problems in any mature Intune environment. Devices get reimaged, employees leave, hardware gets retired — and unless something actively removes those records, your admin center fills up with ghost entries that pollute compliance reports, skew license counts, and make targeting with dynamic groups unreliable. Intune has a built-in answer: device cleanup rules, which automatically hide devices that haven't checked in within a configurable window (30–270 days).
The problem is that the cleanup rule configuration lives in the portal and there's no native export or audit trail. If you're managing multiple tenants, or you just want to enforce a consistent cleanup policy as code, you need the Graph API. This script does three things: reads the current cleanup rule configuration from your tenant, optionally updates it to a target value you specify, and produces a structured report of devices that would be or already have been hidden based on last check-in time. Run it interactively during an audit, or drop it into a scheduled task for weekly reporting.
Prerequisites:
- PowerShell 7.2 or later (5.1 works but you lose
Invoke-RestMethodimprovements) Microsoft.Graph.Authenticationmodule (v2.x recommended)- Graph API permissions:
DeviceManagementManagedDevices.ReadWrite.Allfor write operations;DeviceManagementManagedDevices.Read.Allfor read-only audit - An app registration or interactive login with the above scopes — the script supports both
The Complete Script
#Requires -Version 7.2
#Requires -Modules Microsoft.Graph.Authentication
<#
.SYNOPSIS
Audits and optionally configures Intune device cleanup rules via Microsoft Graph.
.DESCRIPTION
Reads the current Intune device cleanup rule configuration, reports on devices
that fall outside the active check-in window, and optionally updates the cleanup
threshold to a specified number of days.
.PARAMETER TenantId
Azure AD tenant ID. Required for app-based authentication.
.PARAMETER ClientId
App registration client ID. Required for app-based authentication.
.PARAMETER ClientSecret
App registration client secret. Required for app-based authentication.
.PARAMETER SetCleanupDays
If specified, updates the cleanup rule to this value (30–270 days).
Omit to run in read-only audit mode.
.PARAMETER ExportCsv
If specified, exports the stale device report to this CSV path.
.PARAMETER InteractiveLogin
Use delegated (interactive) authentication instead of app credentials.
.EXAMPLE
# Audit only, interactive login
.\Invoke-IntuneDeviceCleanupAudit.ps1 -InteractiveLogin
.EXAMPLE
# Set cleanup rule to 90 days and export report
.\Invoke-IntuneDeviceCleanupAudit.ps1 -TenantId "contoso.onmicrosoft.com" `
-ClientId "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" `
-ClientSecret "" `
-SetCleanupDays 90 `
-ExportCsv "C:\Reports\StaleDevices.csv"
#>
[CmdletBinding(SupportsShouldProcess)]
param (
[Parameter(ParameterSetName = 'AppAuth', Mandatory)]
[string]$TenantId,
[Parameter(ParameterSetName = 'AppAuth', Mandatory)]
[string]$ClientId,
[Parameter(ParameterSetName = 'AppAuth', Mandatory)]
[string]$ClientSecret,
[Parameter(ParameterSetName = 'Interactive', Mandatory)]
[switch]$InteractiveLogin,
[Parameter()]
[ValidateRange(30, 270)]
[int]$SetCleanupDays,
[Parameter()]
[string]$ExportCsv
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
#region --- Authentication ---
function Connect-ToGraph {
[CmdletBinding()]
param (
[string]$TenantId,
[string]$ClientId,
[string]$ClientSecret,
[switch]$Interactive
)
$scopes = @(
'DeviceManagementManagedDevices.ReadWrite.All',
'DeviceManagementConfiguration.ReadWrite.All'
)
if ($Interactive) {
Write-Verbose 'Connecting interactively to Microsoft Graph...'
Connect-MgGraph -Scopes $scopes -NoWelcome
}
else {
Write-Verbose "Connecting as app $ClientId to tenant $TenantId..."
$body = @{
Grant_Type = 'client_credentials'
Scope = 'https://graph.microsoft.com/.default'
Client_Id = $ClientId
Client_Secret = $ClientSecret
}
$tokenResponse = Invoke-RestMethod \
-Uri "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" \
-Method POST \
-Body $body
Connect-MgGraph -AccessToken ($tokenResponse.access_token | ConvertTo-SecureString -AsPlainText -Force) -NoWelcome
}
}
#endregion
#region --- Graph Helpers ---
function Invoke-GraphRequest {
[CmdletBinding()]
param (
[string]$Uri,
[string]$Method = 'GET',
[hashtable]$Body
)
$params = @{
Uri = $Uri
Method = $Method
}
if ($Body) {
$params['ContentType'] = 'application/json'
$params['Body'] = ($Body | ConvertTo-Json -Depth 10)
}
try {
Invoke-MgGraphRequest @params
}
catch {
$statusCode = $_.Exception.Response.StatusCode.value__
Write-Error "Graph API call failed [$statusCode]: $($_.Exception.Message)"
throw
}
}
function Get-AllPages {
param ([string]$Uri)
$results = [System.Collections.Generic.List[object]]::new()
do {
$response = Invoke-GraphRequest -Uri $Uri
if ($response.value) { $results.AddRange($response.value) }
$Uri = $response.'@odata.nextLink'
} while ($Uri)
return $results
}
#endregion
#region --- Core Logic ---
function Get-CleanupRuleConfig {
$uri = 'https://graph.microsoft.com/beta/deviceManagement/deviceCleanupRules'
$response = Invoke-GraphRequest -Uri $uri
return $response
}
function Set-CleanupRuleConfig {
[CmdletBinding(SupportsShouldProcess)]
param ([int]$Days)
$uri = 'https://graph.microsoft.com/beta/deviceManagement/deviceCleanupRules'
$body = @{
deviceInactivityBeforeRetirementInDays = $Days
}
if ($PSCmdlet.ShouldProcess("Intune Tenant", "Set device cleanup rule to $Days days")) {
Invoke-GraphRequest -Uri $uri -Method PATCH -Body $body | Out-Null
Write-Host "[OK] Cleanup rule updated to $Days days." -ForegroundColor Green
}
}
function Get-StaleDeviceReport {
param ([int]$ThresholdDays)
$cutoff = (Get-Date).AddDays(-$ThresholdDays).ToUniversalTime()
Write-Verbose "Fetching all managed devices. Cutoff: $cutoff"
$uri = 'https://graph.microsoft.com/beta/deviceManagement/managedDevices?$select=id,deviceName,operatingSystem,lastSyncDateTime,enrolledDateTime,managedDeviceOwnerType,complianceState,userPrincipalName'
$devices = Get-AllPages -Uri $uri
Write-Verbose "Total devices retrieved: $($devices.Count)"
$stale = $devices | Where-Object {
$lastSync = [datetime]$_.lastSyncDateTime
$lastSync -lt $cutoff
} | ForEach-Object {
$daysSinceSync = [math]::Round(((Get-Date).ToUniversalTime() - [datetime]$_.lastSyncDateTime).TotalDays, 1)
[PSCustomObject]@{
DeviceName = $_.deviceName
OS = $_.operatingSystem
UPN = $_.userPrincipalName
LastSync = ([datetime]$_.lastSyncDateTime).ToString('yyyy-MM-dd HH:mm')
DaysSinceSync = $daysSinceSync
EnrolledDate = ([datetime]$_.enrolledDateTime).ToString('yyyy-MM-dd')
ComplianceState = $_.complianceState
OwnerType = $_.managedDeviceOwnerType
DeviceId = $_.id
}
} | Sort-Object DaysSinceSync -Descending
return $stale
}
#endregion
#region --- Main Execution ---
try {
if ($InteractiveLogin) {
Connect-ToGraph -Interactive
}
else {
Connect-ToGraph -TenantId $TenantId -ClientId $ClientId -ClientSecret $ClientSecret
}
# Read current config
Write-Host "`n=== Current Device Cleanup Rule Configuration ===" -ForegroundColor Cyan
$currentConfig = Get-CleanupRuleConfig
$currentDays = $currentConfig.deviceInactivityBeforeRetirementInDays
if ($null -eq $currentDays -or $currentDays -eq 0) {
Write-Host " Status : NOT CONFIGURED" -ForegroundColor Yellow
$effectiveDays = if ($SetCleanupDays) { $SetCleanupDays } else { 90 }
Write-Host " Using threshold of $effectiveDays days for audit report." -ForegroundColor Yellow
}
else {
Write-Host " Inactivity Threshold : $currentDays days" -ForegroundColor White
$effectiveDays = $currentDays
}
# Optionally update the rule
if ($PSBoundParameters.ContainsKey('SetCleanupDays')) {
Set-CleanupRuleConfig -Days $SetCleanupDays
$effectiveDays = $SetCleanupDays
}
# Generate stale device report
Write-Host "`n=== Stale Device Report (threshold: $effectiveDays days) ===" -ForegroundColor Cyan
$staleDevices = Get-StaleDeviceReport -ThresholdDays $effectiveDays
if ($staleDevices.Count -eq 0) {
Write-Host " No stale devices found. Environment looks healthy." -ForegroundColor Green
}
else {
Write-Host " Stale devices found: $($staleDevices.Count)" -ForegroundColor Yellow
$staleDevices | Format-Table DeviceName, OS, UPN, LastSync, DaysSinceSync, ComplianceState -AutoSize
if ($ExportCsv) {
$staleDevices | Export-Csv -Path $ExportCsv -NoTypeInformation -Encoding UTF8
Write-Host " Report exported to: $ExportCsv" -ForegroundColor Green
}
}
# Summary
Write-Host "`n=== Summary ===" -ForegroundColor Cyan
Write-Host " Cleanup threshold : $effectiveDays days"
Write-Host " Stale devices : $($staleDevices.Count)"
Write-Host " Report generated : $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
}
catch {
Write-Error "Script failed: $_"
exit 1
}
finally {
Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null
}
#endregion
How It Works
Reading the Cleanup Rule
The cleanup rule lives at a single endpoint under deviceManagement/deviceCleanupRules in the beta Graph API. The key property is deviceInactivityBeforeRetirementInDays. If the rule has never been configured, this comes back as null or 0 — the script detects that and falls back to a sensible 90-day default for the audit so you still get useful output even from unconfigured tenants. That's a real edge case I've hit in newly provisioned tenants.
Updating the Rule
The PATCH call to the same endpoint is straightforward, but the script wraps it in SupportsShouldProcess so you get proper -WhatIf and -Confirm behavior. In practice, being able to run -WhatIf before touching a production tenant is non-negotiable for anything that modifies config. The ValidateRange(30, 270) attribute on $SetCleanupDays mirrors the actual portal constraints — the API will reject values outside this range anyway, but failing fast in PowerShell gives a cleaner error message.
Stale Device Discovery
The interesting part is the device enumeration. The script uses a paginated Get-AllPages helper because large tenants routinely exceed the 1,000-device default page size. It uses $select to pull only the fields we care about — skipping this and taking the full device object would triple the payload size and slow everything down noticeably at scale. The cutoff calculation converts to UTC before comparison because lastSyncDateTime values from Graph are always UTC, and local time comparisons have burned me before in tenants spanning multiple regions.
Authentication Flexibility
The script supports both interactive delegated auth (for one-off admin use) and client credentials (for automation). The client credentials flow gets a raw token via Invoke-RestMethod and passes it into Connect-MgGraph as a SecureString — this is the pattern that works reliably with the v2 SDK when you're not using a certificate. If you're operationalizing this in a pipeline, swap the secret for a managed identity or certificate-based auth and adjust accordingly.
Usage & Parameters
# Read-only audit with interactive login — safest way to run for the first time
.\Invoke-IntuneDeviceCleanupAudit.ps1 -InteractiveLogin
# Preview what updating to 60 days would do, without making changes
.\Invoke-IntuneDeviceCleanupAudit.ps1 -InteractiveLogin -SetCleanupDays 60 -WhatIf
# Unattended run via app registration, enforce 90-day rule, export report
.\Invoke-IntuneDeviceCleanupAudit.ps1 `
-TenantId "contoso.onmicrosoft.com" `
-ClientId "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" `
-ClientSecret "your-secret-here" `
-SetCleanupDays 90 `
-ExportCsv "C:\Reports\StaleDevices_$(Get-Date -Format yyyyMMdd).csv"
# Verbose output to see page-by-page device retrieval progress
.\Invoke-IntuneDeviceCleanupAudit.ps1 -InteractiveLogin -Verbose
Sample console output:
=== Current Device Cleanup Rule Configuration === Inactivity Threshold : 90 days === Stale Device Report (threshold: 90 days) === Stale devices found: 14 DeviceName OS UPN LastSync DaysSinceSync ComplianceState ---------- -- --- -------- ------------- --------------- CORP-LT-0042 Windows j.smith@contoso.com 2024-09-11 08:22 201.3 noncompliant CORP-LT-0078 Windows (Unknown) 2024-10-01 14:55 181.7 unknown MBP-MKTG-003 macOS a.jones@contoso.com 2024-10-15 09:10 167.4 compliant ... === Summary === Cleanup threshold : 90 days Stale devices : 14 Report generated : 2025-03-25 10:42:17
Customization Ideas
- Break down by OS or ownership type: Add a
Group-Object OSorGroup-Object OwnerTypeafter the stale device query to produce a per-platform summary — useful when you're trying to understand whether the stale record problem is concentrated in corporate vs. BYOD devices. - Scheduled weekly reporting: Wrap the script in a PowerShell Azure Automation runbook, store the client secret in a Key Vault-linked credential, and schedule it weekly. Email the CSV via
Send-MgUserMaildirectly from the runbook. - Add enrolled-but-never-synced detection: Filter for devices where
lastSyncDateTimeequalsenrolledDateTimewithin a small tolerance — these are devices that enrolled and never ran a full policy sync, a different category of stale that cleanup rules alone won't catch. - Multi-tenant support: Wrap the core logic in a
foreachloop over a CSV of tenant IDs and client credentials, aggregate results into a single report. Useful for MSPs or organizations with multiple Intune tenants. - Extend the
$selectfields: AddserialNumber,model, andmanufacturerto the Graph query's$selectparameter to make the export actionable for asset management — you can cross-reference against your CMDB to confirm whether the device physically still exists.