<# .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 and evaluates staleness in two passes: 1. Entra ID devices, cross-referenced with their matching Intune managed device (if any), matched via the device's Azure AD device ID. "Last activity" is the more recent of the Intune managed device's LastSyncDateTime (last MDM check-in) and the Entra ID device's ApproximateLastSignInDateTime (last sign-in with the device identity). A device is considered stale only when BOTH available signals are older than the -InactiveDays threshold, to minimize the risk of removing a device that is active under one signal but temporarily quiet under the other. When stale, the script removes the Intune managed device object (if any) and then the Entra ID device object itself. 2. Intune managed devices that have NO matching Entra ID device object at all (e.g. the Entra ID device was already deleted manually, or sync between Intune and Entra ID is broken). These are invisible to pass 1, since it is driven from the Entra ID device list. For these, staleness is judged purely by LastSyncDateTime, and only the Intune managed device object is removed (there is no Entra ID device object to remove). Devices with no usable activity signal at all cannot be judged reliably and are reported as 'Unknown' rather than removed. Every row in the output has a 'Source' column ('EntraID' or 'Intune') so it's clear which pass found it and what was actually evaluated/removed. When a removal attempt fails, the 'ErrorDetail' column holds the underlying error message(s) so the cause is visible directly in the report, without needing to correlate back to the -Warning output. The 'TrustType' column (Entra ID rows only - 'AzureAd' cloud joined, 'ServerAd' hybrid joined, 'Workplace' registered/BYOD) helps explain permission-related removal failures, since deleting a Workplace-registered device can require different rights than deleting a joined one. The 'AutopilotRegistered' column is purely informational - it flags devices that still have an active Windows Autopilot device identity, in case that turns out to be relevant to a removal failure, but it does not change whether removal is attempted. This lookup is best-effort: the Autopilot/Intune enrollment backend can return transient errors (e.g. HTTP 500) independently of the rest of the script; if it fails, the column shows 'Unknown' for every device instead of aborting the whole run. The output only lists devices that were removed, attempted-but-failed, skipped (e.g. under -WhatIf), or flagged as 'Unknown' for manual review - healthy, still active devices are deliberately left out of the report to keep it focused on what needs attention. Use -Verbose to see how many active devices were excluded. This script reads Windows Autopilot device identities only to populate the 'AutopilotRegistered' column above - it never modifies or removes an Autopilot registration. The hardware hash entry is always left in place, so a device can always be re-provisioned via Autopilot later, even after its Entra ID / Intune objects have been cleaned up (when that succeeds). Hybrid Azure AD joined devices (TrustType 'ServerAd') are never removed - their Entra ID device object is synced from on-premises Active Directory, and Entra ID rejects deleting it directly, so removal must happen by deleting/disabling the on-prem computer object and letting Entra Connect sync the deletion. A hybrid device is only mentioned (via -Verbose, not in the output) when it would otherwise have been flagged stale and removed - active hybrid devices are not called out at all, same as any other active device. Because these deletions are effectively irreversible, run with -WhatIf first to preview exactly what would be removed - each candidate device is still reported individually. For a real run, instead of prompting once per device/operation, the script asks for a single upfront confirmation ("This will permanently remove ... Continue?") before touching anything; declining aborts the entire run with nothing removed. Pass -Confirm:$false to skip even that single prompt (e.g. for unattended/ scheduled execution). Requires an account with sufficient Entra ID / Intune role assignments (e.g. Cloud Device Administrator + Intune Administrator, or Global Administrator) and the following Microsoft Graph delegated scopes: Device.Read.All Directory.ReadWrite.All DeviceManagementManagedDevices.ReadWrite.All DeviceManagementServiceConfig.ReadWrite.All (read-only use: listing Autopilot device identities for the 'AutopilotRegistered' column; no Graph permission model exists for read-only access to this resource, so ReadWrite.All is the minimum available scope even though this script never writes to it) .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