Stale device records pile up in Entra ID faster than most administrators expect. A laptop that left the organisation a year ago, a wiped iPad, an old Autopilot test machine: each one keeps a directory object, often a registered owner, sometimes even an active certificate. Over time these phantom devices skew Conditional Access scope, distort compliance numbers in Intune, and make every risk report look noisier than it really is. This post walks through a small but practical PowerShell workflow that finds these records, classifies them, and either reports on or removes them with full auditability.

Why stale devices matter

Entra ID stamps every device with two timestamps that you can use as a freshness signal: approximateLastSignInDateTime and registrationDateTime. A device that has not signed in for 90 or 180 days is almost certainly gone, retired, or wiped, yet it can still match Conditional Access policies that target “All devices” and skew Intune compliance dashboards. Disabling these devices first (rather than deleting them) gives you a soft window to recover from mistakes, and lets the noise drop out of policy evaluation immediately.

Prerequisites

  • PowerShell 7.4 or later (Windows PowerShell 5.1 also works for the Graph SDK, but is slower).
  • Microsoft.Graph PowerShell module, version 2.20 or newer: Install-Module Microsoft.Graph -Scope CurrentUser.
  • An Entra ID account or service principal with the Cloud Device Administrator or Intune Administrator role. Read-only auditing only needs Global Reader.
  • The Graph permissions Device.Read.All for reporting and Device.ReadWrite.All for disable or delete operations.

Step 1: Connect and pull the device inventory

Start by connecting to Graph with the minimum scopes you need. For this run we want both read and write so the same script can audit and act:

Connect-MgGraph -Scopes 'Device.ReadWrite.All','Directory.Read.All' -NoWelcome

# Beta endpoint exposes approximateLastSignInDateTime reliably
Select-MgProfile -Name beta

$cutoff = (Get-Date).AddDays(-180)

$devices = Get-MgDevice -All -Property `
    Id, DisplayName, OperatingSystem, OperatingSystemVersion, `
    AccountEnabled, TrustType, ApproximateLastSignInDateTime, `
    RegistrationDateTime, IsCompliant, IsManaged

Write-Host ("Pulled {0} device records" -f $devices.Count)

Pulling every device with a single Get-MgDevice -All is fine for tenants with up to roughly 50,000 objects. For very large estates, page in batches with -PageSize 999 and stream the results to a CSV instead of holding them all in memory.

Step 2: Classify what is actually stale

“Stale” should never mean “no sign-in for X days” by itself. A long-lived server, an offline lab device, or a freshly enrolled machine waiting for first user logon will all trip a naive filter. The classification below treats a device as stale only if it has both an old last sign-in and a registration older than the cutoff, which avoids flagging just-enrolled devices that have not yet phoned home:

$report = foreach ($d in $devices) {
    $lastSignIn = $d.ApproximateLastSignInDateTime
    $registered = $d.RegistrationDateTime

    $isStale = $false
    if ($lastSignIn -and $lastSignIn -lt $cutoff -and $registered -lt $cutoff) {
        $isStale = $true
    }
    elseif (-not $lastSignIn -and $registered -lt $cutoff) {
        # Never signed in and registered long ago: almost certainly abandoned
        $isStale = $true
    }

    [pscustomobject]@{
        Id            = $d.Id
        DisplayName   = $d.DisplayName
        OS            = "$($d.OperatingSystem) $($d.OperatingSystemVersion)"
        TrustType     = $d.TrustType
        Enabled       = $d.AccountEnabled
        Managed       = $d.IsManaged
        Compliant     = $d.IsCompliant
        LastSignIn    = $lastSignIn
        Registered    = $registered
        Stale         = $isStale
    }
}

$staleDevices = $report | Where-Object Stale
$staleDevices | Export-Csv .\stale-devices.csv -NoTypeInformation -Encoding UTF8
Write-Host ("{0} stale candidates written to stale-devices.csv" -f $staleDevices.Count)

Open the CSV, share it with the device owners or service desk, and let it sit for a week. Devices that disappear from the next run because someone logged in are exactly the false positives you want to catch before you act.

Step 3: Disable first, delete later

Hard-deleting an Entra device is irreversible and breaks any reporting that relied on the object’s history. The safer pattern is a two-stage cleanup: disable the device first, wait a grace period, then delete only what is still disabled. Disabling immediately removes the device from policy evaluation without losing the audit trail.

$graceDisableDays = 30

foreach ($d in $staleDevices) {
    if ($d.Enabled -and $d.LastSignIn -lt $cutoff) {
        try {
            Update-MgDevice -DeviceId $d.Id -AccountEnabled:$false
            Write-Host ("Disabled {0} ({1})" -f $d.DisplayName, $d.Id)
        }
        catch {
            Write-Warning ("Could not disable {0}: {1}" -f $d.DisplayName, $_.Exception.Message)
        }
    }
    elseif (-not $d.Enabled -and $d.LastSignIn -and $d.LastSignIn -lt (Get-Date).AddDays(-$graceDisableDays - 180)) {
        try {
            Remove-MgDevice -DeviceId $d.Id -Confirm:$false
            Write-Host ("Removed long-disabled device {0}" -f $d.DisplayName)
        }
        catch {
            Write-Warning ("Could not remove {0}: {1}" -f $d.DisplayName, $_.Exception.Message)
        }
    }
}

Pair this with a -WhatIf switch in your wrapper script during the first few runs. Every Update-MgDevice and Remove-MgDevice action lands in the Entra ID audit log under Update device and Delete device respectively, which makes after-the-fact review straightforward.

Putting it all together

Schedule the script as a daily Azure Automation runbook or a GitHub Actions workflow running on a self-hosted Windows runner, using a managed identity or a workload-identity federation to avoid storing client secrets. A common cadence looks like: report daily, disable weekly, delete monthly. Pipe the CSV into your ticketing system so each disabled device opens a low-priority ticket; if a user reopens the ticket, you re-enable the device with one Graph call.

The result is a tenant where Conditional Access scope reflects reality, Intune compliance numbers match what Helpdesk actually sees, and risk reports stop counting laptops that have not existed since 2024. It is one of the highest-impact, lowest-risk hygiene tasks you can automate, and the entire pipeline fits in roughly fifty lines of PowerShell.

Closing thoughts

Device hygiene is the kind of work that almost never makes a roadmap, but pays dividends every time a security audit lands on your desk. Start with the read-only report, agree the cutoff with your security team, and only then wire in the disable and delete steps. Once it is running, the only thing you should ever notice is that your dashboards stop lying to you.