Cleaning up inactive Intune and Entra ID devices

Device Clean-up Rules within Intune is a simple feature that play a critical role in maintaining an organized and up-to-date Intune environment. Ensuring that your Intune device inventory remains lean and efficient is essential for several reasons, I will dig into that. But also Entra ID registered devices should be cleaned up regularly. I have seen lots of environments with thousands of inactive Entra ID devices.

Why Intune Device Clean-up Matter

  1. Security Considerations:
    • Inactive or obsolete devices can pose security vulnerabilities.
      If not properly managed or removed, these devices might still have access to your organization’s network and resources, potentially falling into the wrong hands.
  2. Clutter Reduction and Accuracy:
    • Cleaning up inactive devices reduces clutter and enhances inventory accuracy.
      They can skew device compliance reporting with old and inaccurate status.
  3. Cost Savings:
    • In environments where licenses are device-based, removing inactive devices frees up unused licenses.
      This leads to cost savings and efficient license utilization.

Configure Intune Device Cleanup Rule

  1. Navigate to the Devices blade in the Microsoft Intune admin center
  2. Scroll down and select the node Device clean-up rules (Yes I´ts an “s” on “rules”, but we can only have 1😂)
  3. Enable the cleanup rule to delete devices that have not checked in for a specified number of days (between 30 and 270 days)

Note: a short period of 30 days might be to short in some environment. I usually keep the default 90-day threshold to maintain a good device accuracy.

Behind the Scenes

After enabling the rule, Intune services run a background job to remove applicable devices from the Intune portal. Devices are not removed from Entra ID, the tenant administrator must perform the cleanup task there. The rule affects all supported devices: Android, iOS, Windows, MacOS, and Linux. This cleanup is quite harmful, some devices may return to the Intune portal if they successfully check in subsequently. This behavior helps recover devices that are for example owned by employees on extended leave.

You will also be presented with a list of affected devices when you save the rule:

Note: It does not perform device wipe or retirement; it simply removes orphaned devices from the Intune portal. In some cases when the old device gets active again, the device need to reenroll to be able to access company data.

What about cleaning up the Entra ID device objects?

The above Intune clean-up rule only refer to Intune device objects. To efficiently clean up stale devices in your Microsoft Entra ID environment, you need to also clean-up stale Entra ID devices.

Detect Stale Devices

  • A stale device is one that has been registered with Microsoft Entra ID but hasn’t accessed any cloud apps within a specific timeframe.
  • The key property for detecting stale devices is the ApproximateLastLogonTimestamp (also known as the activity timestamp).
  • If the delta between the current time and the activity timestamp exceeds your defined timeframe for active devices, the device is considered stale.

Activity Timestamp is updated when

  • Conditional Access policies requiring managed devices or approved client apps.
  • Active Windows 10/11 that are either Microsoft Entra joined or Microsoft Entra hybrid joined.
  • Intune managed devices with an active sync schedule

Configure Entra ID Device Cleanup

This sounds like a simple thing to do. Entra ID has an activity timestamp and we can easy determine stale device objects. But there is no Entra ID Device Clean-up rules. You need to do this manually or build an automation. You can also disable the device object, then you can enable it again in case of problems.

Manual Entra ID device clean-up

  1. Navigate to the All Devices under Devices in the Microsoft Entra admin center
  2. Sort the device object on activity with the oldest on top
  3. Select the devices to remove and click the button delete (or Disable)

But hey! The button Delete is grayed out?

This is due to AutoPilot registered devices that cannot be deleted. These must be deleted in Intune from the AutoPilot registered devices.

We also need to consider the Entra Hybrid joined devices. They can be deleted but they are synced from and needs to also be deleted in On-Premise Active Directory.

Automate Entra ID device clean-up

  1. Create an Managed Identity to run your runbook
    https://www.tbone.se/2024/01/04/create-a-managed-identity-to-automate-intune-tasks/
  2. Assign the managed identity permission: “Device.ReadWrite.All” to be able to find and delete device objects.
  3. In the automation account, add the powershell modules: “Microsoft.Graph.Authentication” and “Microsoft.Graph.identity.DirectoryManagement”
  4. In the runbook, add the following script from my github:
<#PSScriptInfo
.SYNOPSIS
    Script for Entra ID to cleanup stale device objects
 
.DESCRIPTION
    This script will get the Entra device objects 
    The script then compare the ApproximateLastSignInDateTime with the cleanup threshold and remove the device if it is older than the threshold 
    The script uses Ms Graph with MGGraph modules
        
.EXAMPLE
   .\Entra-Cleanup-StaleDevices.ps1
    Will cleanup stale devices 

.NOTES
    Written by Mr-Tbone (Tbone Granheden) Coligo AB
    torbjorn.granheden@coligo.se

.VERSION
    1.0

.RELEASENOTES
    1.0 2023-02-14 Initial Build

.AUTHOR
    Tbone Granheden 
    @MrTbone_se

.COMPANYNAME 
    Coligo AB

.GUID 
    00000000-0000-0000-0000-000000000000

.COPYRIGHT
    Feel free to use this, But would be grateful if My name is mentioned in Notes 

.CHANGELOG
    1.0.2202.1 - Initial Version
#>

#region ---------------------------------------------------[Set script requirements]-----------------------------------------------
#
#Requires -Modules Microsoft.Graph.Authentication
#Requires -Modules Microsoft.Graph.identity.DirectoryManagement
#
#endregion

#region ---------------------------------------------------[Script Parameters]-----------------------------------------------
#endregion

#region ---------------------------------------------------[Modifiable Parameters and defaults]------------------------------------
# Customizations
[int]$DeviceDisableThreshold = 60        # Number of inactive days to determine a stale device to disable
[int]$DeviceDeleteThreshold  = 90        # Number of inactive days to determine a stale device to delete
[Bool]$TestMode             = $true    # $True = No devices will be deleted, $False = Stale devices will be deleted
[Bool]$Verboselogging       = $True     # $True = Enable verbose logging for t-shoot. $False = Disable Verbose Logging
#endregion

#region ---------------------------------------------------[Set global script settings]--------------------------------------------
Set-StrictMode -Version Latest
#endregion

#region ---------------------------------------------------[Import Modules and Extensions]-----------------------------------------
import-Module Microsoft.Graph.Authentication
import-Module Microsoft.Graph.identity.DirectoryManagement
#endregion

#region ---------------------------------------------------[Static Variables]------------------------------------------------------
[System.Collections.ArrayList]$RequiredScopes   = @("Device.ReadWrite.All")
[datetime]$scriptStartTime                      = Get-Date
[string]$disableDate = "$(($scriptStartTime).AddDays(-$DeviceDisableThreshold).ToString("yyyy-MM-dd"))T00:00:00z"
[string]$deleteDate = "$(($scriptStartTime).AddDays(-$DeviceDeleteThreshold).ToString("yyyy-MM-dd"))T00:00:00z"
if ($Verboselogging){$VerbosePreference         = "Continue"}
else{$VerbosePreference                         = "SilentlyContinue"}
#endregion

#region ---------------------------------------------------[Functions]------------------------------------------------------------
function ConnectTo-MgGraph {
    param (
        [System.Collections.ArrayList]$RequiredScopes
    )
    Begin {
        $ErrorActionPreference = 'stop'
        [String]$resourceURL = "https://graph.microsoft.com/"
        $GraphAccessToken = $null
        if ($env:AUTOMATION_ASSET_ACCOUNTID) {  [Bool]$ManagedIdentity = $true}  # Check if running in Azure Automation
        else {                                  [Bool]$ManagedIdentity = $false} # Otherwise running in Local PowerShell
        }
    Process {
        if ($ManagedIdentity){ #Connect to the Microsoft Graph using the ManagedIdentity and get the AccessToken
            Try{$response = [System.Text.Encoding]::Default.GetString((Invoke-WebRequest -UseBasicParsing -Uri "$($env:IDENTITY_ENDPOINT)?resource=$resourceURL" -Method 'GET' -Headers @{'X-IDENTITY-HEADER' = "$env:IDENTITY_HEADER"; 'Metadata' = 'True'}).RawContentStream.ToArray()) | ConvertFrom-Json 
                $GraphAccessToken = $response.access_token
                Write-verbose "$(Get-Date -Format 'yyyy-MM-dd'),$(Get-Date -format 'HH:mm:ss'),Success to get an Access Token to Graph for managed identity"
                }
            Catch{Write-Error "$(Get-Date -Format 'yyyy-MM-dd'),$(Get-Date -format 'HH:mm:ss'),Failed to get an Access Token to Graph for managed identity, with error: $_"}
            $GraphVersion = ($GraphVersion = (Get-Module -Name 'Microsoft.Graph.Authentication' -ErrorAction SilentlyContinue).Version | Sort-Object -Desc | Select-Object -First 1)
            if ('2.0.0' -le $GraphVersion) {
                Try{Connect-MgGraph -Identity -Nowelcome
                    $GraphAccessToken = convertto-securestring($response.access_token) -AsPlainText -Force
                    Write-verbose "$(Get-Date -Format 'yyyy-MM-dd'),$(Get-Date -format 'HH:mm:ss'),Success to connect to Graph with module 2.x and Managedidentity"}
                Catch{Write-Error "$(Get-Date -Format 'yyyy-MM-dd'),$(Get-Date -format 'HH:mm:ss'),Failed to connect to Graph with module 2.x and Managedidentity, with error: $_"}
                }
            else {#Connect to the Microsoft Graph using the AccessToken
                Try{Connect-mgGraph -AccessToken $GraphAccessToken -NoWelcome
	                Write-verbose "$(Get-Date -Format 'yyyy-MM-dd'),$(Get-Date -format 'HH:mm:ss'),Success to connect to Graph with module 1.x and Managedidentity"}
                Catch{Write-Error "$(Get-Date -Format 'yyyy-MM-dd'),$(Get-Date -format 'HH:mm:ss'),Failed to connect to Graph with module 1.x and Managedidentity, with error: $_"}
                }
            }
        else{
            Try{Connect-MgGraph -Scope $RequiredScopes
                Write-verbose "$(Get-Date -Format 'yyyy-MM-dd'),$(Get-Date -format 'HH:mm:ss'),Success to connect to Graph manually"}
            Catch{Write-Error "$(Get-Date -Format 'yyyy-MM-dd'),$(Get-Date -format 'HH:mm:ss'),Failed to connect to Graph manually, with error: $_"}
            }
        #Checking if all permissions are granted to the script identity in Graph and exit if not
        [System.Collections.ArrayList]$CurrentPermissions  = (Get-MgContext).Scopes
        foreach ($RequiredScope in $RequiredScopes) {
            if (Compare-Object $currentpermissions $RequiredScope -IncludeEqual | Where-Object -FilterScript {$_.SideIndicator -eq '=='}){
                Write-Verbose "$(Get-Date -Format 'yyyy-MM-dd'),$(Get-Date -format 'HH:mm:ss'),Success, Script identity has a scope permission: $RequiredScope"
                }
            else {Write-Error "$(Get-Date -Format 'yyyy-MM-dd'),$(Get-Date -format 'HH:mm:ss'),Failed, Script identity is missing a scope permission: $RequiredScope"}
            }
        #Return the access token if available and cleanup memory after connecting to Graph
        return $GraphAccessToken
        }
    End {$MemoryUsage = [System.GC]::GetTotalMemory($true)
        Write-Verbose "$(Get-Date -Format 'yyyy-MM-dd'),$(Get-Date -format 'HH:mm:ss'),Success to cleanup Memory usage after connect to Graph to: $(($MemoryUsage/1024/1024).ToString('N2')) MB"
        }   
}
#endregion

#region ---------------------------------------------------[[Script Execution]------------------------------------------------------
$StartTime = Get-Date
$MgGraphAccessToken = ConnectTo-MgGraph -RequiredScopes $RequiredScopes

#Get Pending Devices to disable
try{$pendingdevices = Get-MgDevice -All -Filter "ApproximateLastSignInDateTime le $($disableDate) AND ApproximateLastSignInDateTime ge $($deleteDate)"
    write-verbose "$(Get-Date -Format 'yyyy-MM-dd'),$(Get-Date -format 'HH:mm:ss'),Success to get $($pendingdevices.count) Pending Devices to disable"}
catch{write-Error "$(Get-Date -Format 'yyyy-MM-dd'),$(Get-Date -format 'HH:mm:ss'),Failed to get Pending Devices with error: $_"}

#Get Stale Devices to delete
try{$staledevices = Get-MgDevice -All -Filter "ApproximateLastSignInDateTime le $($deleteDate)"
    write-verbose "$(Get-Date -Format 'yyyy-MM-dd'),$(Get-Date -format 'HH:mm:ss'),Success to get $($staledevices.count) Stale Devices to delete"}
catch{write-Error "$(Get-Date -Format 'yyyy-MM-dd'),$(Get-Date -format 'HH:mm:ss'),Failed to get Stale Devices with error: $_"}

#Disable Pending Devices
foreach ($device in $pendingdevices) {
    Write-Output "Device $($device.DisplayName) is pending to be disabled"
    if ($TestMode -eq $False) {
        try{Update-MgDevice -Id $device.Id -AccountEnabled $False
            write-verbose "$(Get-Date -Format 'yyyy-MM-dd'),$(Get-Date -format 'HH:mm:ss'),Success to disable Device $($device.DisplayName)"}
        catch{write-Error "$(Get-Date -Format 'yyyy-MM-dd'),$(Get-Date -format 'HH:mm:ss'),Failed to disable Device $($device.DisplayName) with error: $_"}
    }
}

#Delete Stale Devices
foreach ($device in $staledevices) {
    Write-Output "Device $($device.DisplayName) is stale and will be removed"
    if ($TestMode -eq $False) {
        try{Remove-MgDevice -Id $device.Id
            write-verbose "$(Get-Date -Format 'yyyy-MM-dd'),$(Get-Date -format 'HH:mm:ss'),Success to remove Device $($device.DisplayName)"}
        catch{write-Error "$(Get-Date -Format 'yyyy-MM-dd'),$(Get-Date -format 'HH:mm:ss'),Failed to remove Device $($device.DisplayName) with error: $_"}
    }
}

$graphApiVersion = "beta"
$Resource = "devices"
$filter = "?`$filter=ApproximateLastSignInDateTime le $($deleteDate)"
$results=@()
$uri = "https://graph.microsoft.com////$graphApiVersion/$($Resource)$($filter)"
$result=Invoke-RestMethod -Uri $uri -Headers $MgGraphAccessToken -Method Get
$results+=$result
#disconnect-mggraph | out-null
[datetime]$scriptEndTime    = Get-Date
write-Output "Script execution time: $(($scriptEndTime-$scriptStartTime).ToString('hh\:mm\:ss'))"
$VerbosePreference = "SilentlyContinue"

Remember, keeping your device inventory clean ensures efficient management and compliance. 🌟🔍💻 In summary, Device Clean-up Rules keep your Intune environment tidy and accurate, ensuring efficient device management

About The Author

Mr T-Bone

Torbjörn Tbone Granheden is a Solution Architect for Modern Workplace at Coligo AB. Most Valuable Professional (MVP) on Enterprise Mobility. Certified in most Microsoft technologies and over 23 years as Microsoft Certified Trainer (MCT)

You may also like...