← Back to articles Intune

Automate Intune Device Cleanup Rules with PowerShell

Automate Intune Device Cleanup Rules with PowerShell

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-RestMethod improvements)
  • Microsoft.Graph.Authentication module (v2.x recommended)
  • Graph API permissions: DeviceManagementManagedDevices.ReadWrite.All for write operations; DeviceManagementManagedDevices.Read.All for 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 OS or Group-Object OwnerType after 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-MgUserMail directly from the runbook.
  • Add enrolled-but-never-synced detection: Filter for devices where lastSyncDateTime equals enrolledDateTime within 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 foreach loop 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 $select fields: Add serialNumber, model, and manufacturer to the Graph query's $select parameter to make the export actionable for asset management — you can cross-reference against your CMDB to confirm whether the device physically still exists.

🎓 Ready to go deeper?

Practice real MD-102 exam questions, get AI feedback on your weak areas, and fast-track your Intune certification.

Start Free Practice → Book a Session
Souhaiel Morhag
Souhaiel Morhag
Microsoft Endpoint & Modern Workplace Engineer

Souhaiel Morhag is a Microsoft Intune and endpoint management specialist with hands-on experience deploying and securing enterprise environments across Microsoft 365. He founded MSEndpoint.com to share practical, real-world guides for IT admins navigating Microsoft technologies — and built the MSEndpoint Academy at app.msendpoint.com/academy, a dedicated learning platform for professionals preparing for the MD-102 (Microsoft 365 Endpoint Administrator) certification. Through in-depth articles and AI-powered practice exams, Souhaiel helps IT teams move faster and certify with confidence.

Related Articles

Popular on MSEndpoint