<# .SYNOPSIS Removes Entra ID devices and Intune managed devices that have not connected in longer than a given number of days. .DESCRIPTION Connects to Microsoft Graph, finds stale Entra ID and Intune-managed devices, and removes the matching device records after checking their last sign-in and last sync activity. It also reports devices that cannot be deleted, devices with no reliable activity signal, and whether an Autopilot registration still exists. .PARAMETER InactiveDays Number of days of inactivity after which a device is considered stale. Defaults to 90. .PARAMETER TenantId Optional tenant ID or verified domain name to sign in to. If omitted, Connect-MgGraph uses the interactive account picker / default tenant. .EXAMPLE .\Remove-InactiveEntraDevice.ps1 -WhatIf Signs in, lists every Entra ID and Intune-only device inactive for more than 90 days, and shows what would be removed without changing anything. .EXAMPLE .\Remove-InactiveEntraDevice.ps1 -InactiveDays 180 -TenantId 'contoso.onmicrosoft.com' Signs in to the specified tenant and, after a single confirmation prompt, removes every device that has not connected in more than 180 days. .EXAMPLE .\Remove-InactiveEntraDevice.ps1 -Confirm:$false Signs in and removes every inactive device without even the single upfront confirmation prompt - use only for unattended/scheduled execution, ideally after a prior -WhatIf run has already been reviewed. .NOTES Author: Petr Stepan Created: 2026-07-17 Version: 1.0.0 Changelog: 1.0.0 - Initial version #> #Requires -Modules Microsoft.Graph.Authentication, Microsoft.Graph.Identity.DirectoryManagement, Microsoft.Graph.DeviceManagement, Microsoft.Graph.DeviceManagement.Enrollment [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] param( [Parameter(Mandatory = $false, HelpMessage = 'Number of days of inactivity after which a device is considered stale.')] [ValidateRange(1, 3650)] [int] $InactiveDays = 180, [Parameter(Mandatory = $false, HelpMessage = 'Tenant ID or verified domain name to connect to.')] [ValidateNotNullOrEmpty()] [string] $TenantId ) # --- Safety preamble ------------------------------------------------------- # Fail fast on uninitialized variables and typos in property/member names. Set-StrictMode -Version Latest # Treat all non-terminating errors as terminating so failures can't slip by silently. $ErrorActionPreference = 'Stop' # Graph scopes needed: read devices, delete devices, manage Intune managed-device # records, and read (never write) Windows Autopilot device identities to populate the # 'AutopilotRegistered' reporting column - this script never removes an Autopilot # registration. $script:RequiredGraphScopes = @( 'Device.Read.All' 'Directory.ReadWrite.All' 'DeviceManagementManagedDevices.ReadWrite.All' 'DeviceManagementServiceConfig.ReadWrite.All' ) # --- Functions --------------------------------------------------------- function Connect-TenantGraph { <# .SYNOPSIS Ensures an authenticated Microsoft Graph session with the required scopes. .DESCRIPTION Read-only from the script's perspective (it only inspects/creates an auth session), so it does not need ShouldProcess. Reuses an existing connection if it already grants every required scope, to avoid re-prompting unnecessarily. .PARAMETER TenantId Optional tenant ID or verified domain name to connect to. .EXAMPLE Connect-TenantGraph -TenantId 'contoso.onmicrosoft.com' #> [CmdletBinding()] param( [Parameter(Mandatory = $false)] [string] $TenantId ) $context = Get-MgContext $hasAllScopes = $context -and (-not (Compare-Object -ReferenceObject $script:RequiredGraphScopes -DifferenceObject $context.Scopes | Where-Object { $_.SideIndicator -eq '<=' })) if (-not $hasAllScopes) { $connectParams = @{ Scopes = $script:RequiredGraphScopes NoWelcome = $true } if ($TenantId) { $connectParams['TenantId'] = $TenantId } Connect-MgGraph @connectParams } else { Write-Verbose 'Reusing existing Microsoft Graph connection with sufficient scopes.' } } function Get-EntraDeviceInventory { <# .SYNOPSIS Returns every Entra ID device object with the properties needed to judge staleness (sign-in recency) and join type (TrustType). .DESCRIPTION Read-only; does not modify anything, so it does not need ShouldProcess. .EXAMPLE Get-EntraDeviceInventory #> [CmdletBinding()] param() try { Get-MgDevice -All -Property 'Id,DeviceId,DisplayName,AccountEnabled,ApproximateLastSignInDateTime,OperatingSystem,TrustType' -ErrorAction Stop } catch { Write-Error "Failed to retrieve devices from Entra ID: $_" throw } } function Get-IntuneManagedDeviceInventory { <# .SYNOPSIS Returns every Intune managed device with the properties needed to judge staleness (sync recency) and to match it to an Entra ID device object. .DESCRIPTION Read-only; does not modify anything, so it does not need ShouldProcess. .EXAMPLE Get-IntuneManagedDeviceInventory #> [CmdletBinding()] param() try { Get-MgDeviceManagementManagedDevice -All -Property 'Id,AzureADDeviceId,DeviceName,LastSyncDateTime' -ErrorAction Stop } catch { Write-Error "Failed to retrieve Intune managed devices: $_" throw } } function Get-AutopilotRegisteredDeviceIdSet { <# .SYNOPSIS Returns the set of Azure AD device IDs that currently have an active Windows Autopilot device identity. .DESCRIPTION Read-only; does not modify anything, so it does not need ShouldProcess. This script never removes an Autopilot registration - the result is used purely to populate the 'AutopilotRegistered' reporting column, since Entra ID/Intune commonly reject deleting a device object while its Autopilot registration is still in place. .EXAMPLE Get-AutopilotRegisteredDeviceIdSet #> [CmdletBinding()] param() try { # Deliberately no -Property/$select here - the windowsAutopilotDeviceIdentities # backend has been observed to return HTTP 500 when a $select is applied, even # though the same call without it succeeds. Fetching full objects costs a # little extra payload but avoids that failure mode entirely. $autopilotIdentities = Get-MgDeviceManagementWindowsAutopilotDeviceIdentity -All -ErrorAction Stop $set = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($identity in $autopilotIdentities) { if ($identity.AzureActiveDirectoryDeviceId) { $set.Add($identity.AzureActiveDirectoryDeviceId) | Out-Null } } # -NoEnumerate is required here: PowerShell unwraps any IEnumerable return # value onto the pipeline element-by-element by default, so a plain # "return $set" would emit the HashSet's individual strings (or nothing at # all if empty) instead of the HashSet object itself. Write-Output -NoEnumerate $set } catch { Write-Error "Failed to retrieve Windows Autopilot device identities: $_" throw } } function Remove-StaleTenantDevice { <# .SYNOPSIS Removes a stale device's Intune managed device object (if any) and, when it has one, its Entra ID device object. Does not touch Autopilot. .DESCRIPTION Each deletion is gated independently behind ShouldProcess so -WhatIf reports exactly what would be removed, and a failure in one step does not prevent the other from being attempted. DeviceObjectId is optional: pass it for a device that has an Entra ID device object to remove; omit it for an Intune-only managed device that has no matching Entra ID object at all. .PARAMETER DisplayName Display name of the device, used only for prompts/logging. .PARAMETER DeviceObjectId The Entra ID device object's Id (not its deviceId property). Omit for an Intune-only device with no Entra ID device object. .PARAMETER ManagedDevices The matching Intune managed device object(s) for this device, if any. .EXAMPLE Remove-StaleTenantDevice -DisplayName 'LAPTOP-01' -DeviceObjectId $objId -ManagedDevices $managedDevices .EXAMPLE Remove-StaleTenantDevice -DisplayName 'LAPTOP-02' -ManagedDevices $managedDevices #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] param( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string] $DisplayName, [Parameter(Mandatory = $false)] [string] $DeviceObjectId, [Parameter(Mandatory = $false)] [object[]] $ManagedDevices = @() ) $errors = [System.Collections.Generic.List[string]]::new() # 'NotFound' only when there was genuinely no matching managed device; if one # exists but ShouldProcess declines (WhatIf/Confirm), that's 'Skipped', not # 'NotFound' - it WAS found, it just wasn't removed. $intuneDeviceStatus = if ($ManagedDevices.Count -gt 0) { 'Skipped' } else { 'NotFound' } $entraDeviceStatus = if ($DeviceObjectId) { 'Skipped' } else { 'N/A' } # 1. Remove the matching Intune managed device object(s), if any exist. if ($ManagedDevices.Count -gt 0 -and $PSCmdlet.ShouldProcess($DisplayName, 'Remove Intune managed device object')) { try { foreach ($managedDevice in $ManagedDevices) { Remove-MgDeviceManagementManagedDevice -ManagedDeviceId $managedDevice.Id -ErrorAction Stop } $intuneDeviceStatus = 'Removed' } catch { $intuneDeviceStatus = 'Failed' $errors.Add("Intune managed device removal failed: $_") Write-Warning "Failed to remove Intune managed device for '$DisplayName': $_" } } # 2. Remove the Entra ID device object itself, if this device has one. if ($DeviceObjectId -and $PSCmdlet.ShouldProcess($DisplayName, 'Remove Entra ID device object')) { try { Remove-MgDevice -DeviceId $DeviceObjectId -ErrorAction Stop $entraDeviceStatus = 'Removed' } catch { $entraDeviceStatus = 'Failed' $errors.Add("Entra ID device removal failed: $_") Write-Warning "Failed to remove Entra ID device object for '$DisplayName': $_" } } return [PSCustomObject]@{ IntuneDeviceStatus = $intuneDeviceStatus EntraDeviceStatus = $entraDeviceStatus Errors = $errors } } # --- Main -------------------------------------------------------------- $results = [System.Collections.Generic.List[PSCustomObject]]::new() $hasFailures = $false $cutoffDate = (Get-Date).ToUniversalTime().AddDays(-$InactiveDays) try { Write-Verbose 'Connecting to Microsoft Graph.' Connect-TenantGraph -TenantId $TenantId # One upfront confirmation for the whole run, instead of a separate interactive # prompt per device/operation. Skipped entirely under -WhatIf (nothing to confirm # for a preview) and honored if the caller explicitly passed -Confirm:$false. # Every individual removal below then runs with -Confirm:$false, since the # decision to proceed has already been made here. if (-not $WhatIfPreference) { $confirmExplicitlySuppressed = $PSBoundParameters.ContainsKey('Confirm') -and -not $PSBoundParameters['Confirm'] if (-not $confirmExplicitlySuppressed -and -not $PSCmdlet.ShouldContinue( "This will permanently remove Entra ID and/or Intune device objects inactive for more than $InactiveDays day(s). This cannot be undone. Continue?", 'Confirm device removal')) { Write-Warning 'Aborted by user - no devices were removed.' exit 0 } } Write-Verbose 'Retrieving device inventory from Entra ID.' $devices = @(Get-EntraDeviceInventory) Write-Verbose "Found $($devices.Count) device(s) in Entra ID." Write-Verbose 'Retrieving Intune managed device inventory.' $managedDevices = @(Get-IntuneManagedDeviceInventory) Write-Verbose "Found $($managedDevices.Count) managed device(s) in Intune." # This lookup is purely informational (populates the 'AutopilotRegistered' column) # and must never abort the whole run - the Autopilot/Intune enrollment backend is # a separate, occasionally flaky service from core Graph device/Intune endpoints. # A failure here just means we can't tell either way, not that nothing else works. $autopilotDeviceIdSet = $null try { Write-Verbose 'Retrieving Windows Autopilot device identities (read-only, for reporting only).' $autopilotDeviceIdSet = Get-AutopilotRegisteredDeviceIdSet Write-Verbose "Found $($autopilotDeviceIdSet.Count) device(s) registered in Windows Autopilot." } catch { Write-Warning "Could not retrieve Windows Autopilot device identities - the 'AutopilotRegistered' column will show 'Unknown' for every device this run. Error: $_" } $managedDeviceMap = @{} foreach ($managedDevice in $managedDevices) { if ($managedDevice.AzureADDeviceId) { if (-not $managedDeviceMap.ContainsKey($managedDevice.AzureADDeviceId)) { $managedDeviceMap[$managedDevice.AzureADDeviceId] = [System.Collections.Generic.List[object]]::new() } $managedDeviceMap[$managedDevice.AzureADDeviceId].Add($managedDevice) } } # Track every Azure AD device ID seen in Entra ID, so pass 2 can tell which # Intune managed devices have no matching Entra ID device object at all. $entraDeviceIdSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) # --- Pass 1: Entra ID devices --------------------------------------- foreach ($device in $devices) { if ($device.DeviceId) { # Register the ID even for devices we're about to skip (e.g. hybrid # joined), so pass 2 doesn't mistake their Intune managed device for an # orphan with no matching Entra ID device object. $entraDeviceIdSet.Add($device.DeviceId) | Out-Null } $matchedManagedDevices = @() if ($device.DeviceId -and $managedDeviceMap.ContainsKey($device.DeviceId)) { $matchedManagedDevices = $managedDeviceMap[$device.DeviceId] } # "Last activity" is the more recent of the Intune check-in and the Entra # sign-in - a device only counts as stale when BOTH signals agree it's old. $lastSyncDateTime = ($matchedManagedDevices | ForEach-Object { $_.LastSyncDateTime } | Sort-Object -Descending | Select-Object -First 1) $lastSignInDateTime = $device.ApproximateLastSignInDateTime # Wrap the whole pipeline in @() - if Where-Object filters out every date, # PowerShell would otherwise collapse the result to $null instead of an empty # array, and $null.Count throws under Set-StrictMode. $candidateDates = @(@($lastSyncDateTime, $lastSignInDateTime) | Where-Object { $_ }) $resultRow = [PSCustomObject]@{ Source = 'EntraID' DisplayName = $device.DisplayName ObjectId = $device.Id AzureAdDeviceId = $device.DeviceId TrustType = $device.TrustType # Informational only - does not affect whether removal is attempted. AutopilotRegistered = if (-not $autopilotDeviceIdSet) { 'Unknown' } elseif ($device.DeviceId -and $autopilotDeviceIdSet.Contains($device.DeviceId)) { 'Yes' } else { 'No' } ManagedDeviceId = if ($matchedManagedDevices.Count -gt 0) { ($matchedManagedDevices.Id -join ', ') } else { $null } LastSyncDateTime = $lastSyncDateTime LastSignInDateTime = $lastSignInDateTime IntuneDeviceStatus = 'N/A' EntraDeviceStatus = 'N/A' OverallStatus = 'Unknown' ErrorDetail = $null } if ($candidateDates.Count -eq 0) { # No usable signal at all - do not guess; flag for manual review instead. # Hybrid devices are the exception: with no signal we can't tell it would # even be stale, so there's nothing actionable to report - stay silent, # same as a hybrid device that turns out to be active. if ($device.TrustType -ne 'ServerAd') { $results.Add($resultRow) } continue } $lastActivityDate = $candidateDates | Sort-Object -Descending | Select-Object -First 1 if ($lastActivityDate -ge $cutoffDate) { $resultRow.OverallStatus = 'Active' $results.Add($resultRow) continue } # The device IS stale at this point - only now does hybrid join type matter. if ($device.TrustType -eq 'ServerAd') { # Hybrid Azure AD joined devices are synced from on-premises Active # Directory; Entra ID rejects deleting their device object directly, so # attempting it would just fail. Skip them - removal has to happen by # deleting/disabling the computer object in on-prem AD and letting Entra # Connect sync propagate it. Only mention devices that would actually have # been removed, not every hybrid device regardless of staleness. Write-Verbose "Skipping hybrid Azure AD joined device '$($device.DisplayName)' (stale since $lastActivityDate) - it must be removed via on-premises Active Directory, not directly from Entra ID." continue } Write-Verbose "Removing stale Entra ID device '$($device.DisplayName)' (last activity: $lastActivityDate)." # Forward -WhatIf explicitly rather than relying on $WhatIfPreference to # cascade implicitly into this sibling function - keeps the dry-run # behavior obvious and independent of scope-inheritance quirks. -Confirm:$false # because the single upfront confirmation above already covers this whole run. $removalResult = Remove-StaleTenantDevice -DisplayName $device.DisplayName -DeviceObjectId $device.Id ` -ManagedDevices $matchedManagedDevices -WhatIf:$WhatIfPreference -Confirm:$false $resultRow.IntuneDeviceStatus = $removalResult.IntuneDeviceStatus $resultRow.EntraDeviceStatus = $removalResult.EntraDeviceStatus $resultRow.OverallStatus = if ($removalResult.Errors.Count -gt 0) { 'Failed' } elseif ($removalResult.EntraDeviceStatus -eq 'Removed') { 'Removed' } else { 'Skipped' } if ($removalResult.Errors.Count -gt 0) { $resultRow.ErrorDetail = $removalResult.Errors -join '; ' } if ($resultRow.OverallStatus -eq 'Failed') { $hasFailures = $true } $results.Add($resultRow) } # --- Pass 2: Intune managed devices with no matching Entra ID device object. # Pass 1 is driven from the Entra ID device list, so an orphaned Intune record # (Entra ID device already gone, or sync never created/lost the link) would # otherwise never be evaluated at all. $orphanManagedDevices = @($managedDevices | Where-Object { -not $_.AzureADDeviceId -or -not $entraDeviceIdSet.Contains($_.AzureADDeviceId) }) Write-Verbose "Found $($orphanManagedDevices.Count) Intune managed device(s) with no matching Entra ID device object." foreach ($managedDevice in $orphanManagedDevices) { $displayName = if ($managedDevice.DeviceName) { $managedDevice.DeviceName } else { $managedDevice.Id } $resultRow = [PSCustomObject]@{ Source = 'Intune' DisplayName = $displayName ObjectId = $null AzureAdDeviceId = $managedDevice.AzureADDeviceId TrustType = 'N/A' AutopilotRegistered = if (-not $autopilotDeviceIdSet) { 'Unknown' } elseif ($managedDevice.AzureADDeviceId -and $autopilotDeviceIdSet.Contains($managedDevice.AzureADDeviceId)) { 'Yes' } else { 'No' } ManagedDeviceId = $managedDevice.Id LastSyncDateTime = $managedDevice.LastSyncDateTime LastSignInDateTime = $null IntuneDeviceStatus = 'N/A' EntraDeviceStatus = 'N/A' OverallStatus = 'Unknown' ErrorDetail = $null } if (-not $managedDevice.LastSyncDateTime) { # No usable signal at all - do not guess; flag for manual review instead. $results.Add($resultRow) continue } if ($managedDevice.LastSyncDateTime -ge $cutoffDate) { $resultRow.OverallStatus = 'Active' $results.Add($resultRow) continue } Write-Verbose "Removing stale Intune-only device '$displayName' (last sync: $($managedDevice.LastSyncDateTime))." $removalResult = Remove-StaleTenantDevice -DisplayName $displayName -ManagedDevices @($managedDevice) -WhatIf:$WhatIfPreference -Confirm:$false $resultRow.IntuneDeviceStatus = $removalResult.IntuneDeviceStatus $resultRow.EntraDeviceStatus = $removalResult.EntraDeviceStatus $resultRow.OverallStatus = if ($removalResult.Errors.Count -gt 0) { 'Failed' } elseif ($removalResult.IntuneDeviceStatus -eq 'Removed') { 'Removed' } else { 'Skipped' } if ($removalResult.Errors.Count -gt 0) { $resultRow.ErrorDetail = $removalResult.Errors -join '; ' } if ($resultRow.OverallStatus -eq 'Failed') { $hasFailures = $true } $results.Add($resultRow) } $activeCount = @($results | Where-Object { $_.OverallStatus -eq 'Active' }).Count Write-Verbose "$activeCount device(s) are still active and excluded from the output below." # Only surface devices the script acted on (or would act on) or flagged for manual # review - healthy 'Active' devices are deliberately omitted to keep the report # focused on what actually needs attention. $results | Where-Object { $_.OverallStatus -ne 'Active' } } catch { Write-Error "Script failed: $_" exit 1 } finally { # No persistent sessions to release - Connect-MgGraph tokens are cached by the SDK. } if ($hasFailures) { exit 1 } exit 0