The computer is gone, but Defender still lists it
You reimaged a computer or removed its old Active Directory account. Its record still appears in Microsoft Defender for Endpoint (MDE). Before looking for a Delete button, identify the actual goal: retiring a device from the service, correcting vulnerability scope, or understanding why two records share a name. Those are different operations.
This guide provides a read-only PowerShell inventory, a conservative AD comparison and optional tagging after approval. None of the examples deletes AD objects, automatically disables protection or claims to force the Inactive state. Device names and change tickets are fictional, not client stories.
Start with this rule: missing from AD and silent for 30 days means review, not delete. A laptop awaiting repair, a device migrated to Entra ID or a sensor with broken connectivity can look like a permanently retired asset.
Inactive, excluded and offboarded are different states
Device exclusion from vulnerability views is not an antivirus file or process exclusion, and it does not remediate a vulnerability. The record remains in the inventory. Never exclude an operational device simply to make a metric look better.
| Action or state | Meaning | Safe use |
|---|---|---|
| Inactive | Health state associated with missing communication | Investigate the cause; it is not proof of retirement. |
| Custom tag | Device classification and filtering | Build a review queue without hiding vulnerabilities. |
| Exclude | Removal from vulnerability management scope | Use for confirmed obsolete or out-of-scope records with justification. |
| Offboarding | Stop the device sending MDE data | Authorized retirement of a reachable device; track completion. |
| Deletion in AD, Entra or Intune | A change in a separate inventory | A separate workflow, not a substitute for MDE offboarding. |
Why old records remain visible
Microsoft documents an Inactive health state after seven days without signals and retention of up to 180 days. Vulnerability management uses a different window: associated vulnerabilities stop appearing after 30 days without reporting; devices with no activity in the past 30 days no longer contribute to the exposure score. A visible record does not necessarily still affect that score.
The Machines API lastSeen field is the time of the last full device report, typically sent daily, and may differ from the portal’s last-seen value. Compare UTC timestamps and allow a margin. Do not trigger changes based on a few minutes around a threshold.
A name can represent successive installations. The MDE DeviceId, aadDeviceId and AD objectGUID are not interchangeable. Keep each record’s DeviceId in the change record; never target a write operation using only a short hostname or IP address.
Define scope and separate permissions
Run the inventory from a Windows administration workstation with PowerShell 7, the RSAT ActiveDirectory module and a recent Az.Accounts version. The Windows account must be able to read every relevant AD domain. This account is separate from the MDE application identity.
Register a dedicated Entra application and grant WindowsDefenderATP application permissions with administrator consent. Start with Machine.Read.All. If tagging is approved, use a separate identity with Machine.ReadWrite.All; reserve Machine.Offboard for a distinct retirement workflow. Azure subscription roles do not replace these API permissions.
Use a protected private key and register the public certificate on the application. A properly authorized managed identity is another option for an Azure-hosted job. Do not keep secrets in scripts or repositories, and never log tokens. These examples target the commercial cloud; adapt authorities and endpoints for sovereign clouds.
Acquire a token without exposing a secret
Requests use api.security.microsoft.com, but Microsoft notes that some APIs still expect a token for the legacy api.securitycenter.microsoft.com resource. The example uses that audience. A Microsoft Graph token is not a substitute.
Replace the three angle-bracket placeholders. The certificate must be accessible to the account running PowerShell. Recent Get-AzAccessToken versions return a SecureString, passed directly to Invoke-RestMethod. Renew the token if the job outlasts its validity, without writing its contents to the report.
# PowerShell 7 on Windows; recent Az.Accounts module.
# Register the public certificate on the app; protect its private key locally.
Import-Module Az.Accounts -ErrorAction Stop
Disable-AzContextAutosave -Scope Process | Out-Null
$connection = @{
ServicePrincipal = $true
Tenant = '<TENANT-ID>'
ApplicationId = '<READ-ONLY-APP-ID>'
CertificateThumbprint = '<CERTIFICATE-THUMBPRINT>'
SkipContextPopulation = $true
Scope = 'Process'
ErrorAction = 'Stop'
}
Connect-AzAccount @connection | Out-Null
$tokenRequest = @{
ResourceUrl = 'https://api.securitycenter.microsoft.com'
TenantId = $connection.Tenant
ErrorAction = 'Stop'
}
$token = (Get-AzAccessToken @tokenRequest).Token
if ($token -isnot [securestring]) {
throw 'Update Az.Accounts: a SecureString token is required.'
}
# Never print the token or include it in a transcript.Inventory MDE and compare with AD without changing anything
Run this block after authentication in the same session. It reads the complete inventory available through the Machines API with explicit pagination, then exports a CSV. Disabled AD computer accounts still count as present. A short-name match is also enough to retain a record: this is deliberately conservative.
AdServers must cover every relevant domain, not just one OU. DnsSuffixes restricts candidates to expected fully qualified names. Set MinimumAdCount to an approved floor for your environment, not an arbitrary copied value. An empty collection, failed domain lookup or invalid API page aborts the report instead of making devices appear absent.
The example uses pages of 1,000 devices; the API supports up to 10,000. It spaces page requests and bounds HTTP retries. Documented limits for this API are 100 calls per minute and 1,500 per hour. PowerShell honours Retry-After on 429 responses. Persistent failures must stop the job; never proceed using a partial inventory.
function Get-MdeRetirementReport {
[CmdletBinding()]
param(
[Parameter(Mandatory)][securestring]$Token,
[Parameter(Mandatory)][string[]]$AdServers,
[Parameter(Mandatory)][string[]]$DnsSuffixes,
[ValidateRange(7,180)][int]$InactiveDays = 30,
[ValidateRange(1,10000000)][int]$MinimumAdCount = 1
)
$ErrorActionPreference = 'Stop'
Import-Module ActiveDirectory -ErrorAction Stop
$now = [datetimeoffset]::UtcNow
$cutoff = $now.AddDays(-$InactiveDays)
$adNames = [Collections.Generic.HashSet[string]]::new(
[StringComparer]::OrdinalIgnoreCase)
$adShortNames = [Collections.Generic.HashSet[string]]::new(
[StringComparer]::OrdinalIgnoreCase)
$adCount = 0
# Query the WHOLE domain for every declared domain, including disabled PCs.
foreach ($server in $AdServers) {
$domainDn = (Get-ADDomain -Server $server).DistinguishedName
if (-not $domainDn) { throw "Missing AD domain root: $server" }
$items = @(Get-ADComputer -Server $server -SearchBase $domainDn -SearchScope Subtree -Filter * -Properties DNSHostName)
if ($items.Count -eq 0) { throw "Empty AD inventory: $server" }
$adCount += $items.Count
foreach ($item in $items) {
if ($item.DNSHostName) {
[void]$adNames.Add($item.DNSHostName.Trim().TrimEnd('.'))
}
[void]$adShortNames.Add($item.Name)
}
}
if ($adCount -lt $MinimumAdCount) { throw 'AD inventory below approved baseline.' }
$machines = [Collections.Generic.List[object]]::new()
$ids = [Collections.Generic.HashSet[string]]::new()
$pageSize = 1000
$skip = 0
do {
if ($skip -ge 200000) { throw 'Inventory cap reached; review paging strategy.' }
$uri = 'https://api.security.microsoft.com/api/machines?$top={0}&$skip={1}' -f $pageSize,$skip
$request = @{
Uri = $uri; Method = 'Get'; Authentication = 'Bearer'; Token = $Token
MaximumRetryCount = 3; RetryIntervalSec = 5; TimeoutSec = 60
MaximumRedirection = 0; ErrorAction = 'Stop'
}
$page = Invoke-RestMethod @request
if ($null -eq $page.value) { throw 'Missing value collection; report aborted.' }
$batch = @($page.value)
foreach ($machine in $batch) {
if (-not $machine.id) { throw 'Missing MDE device ID.' }
if (-not $ids.Add($machine.id)) {
throw 'Repeated device ID while paging; rerun the inventory.'
}
$machines.Add($machine)
}
$skip += $pageSize
if ($batch.Count -eq $pageSize) { Start-Sleep -Seconds 3 }
} while ($batch.Count -eq $pageSize)
if ($machines.Count -eq 0) { throw 'Empty MDE inventory; do not infer retirement.' }
$nameCounts = @{}
foreach ($machine in $machines) {
$name = ([string]$machine.computerDnsName).Trim().TrimEnd('.').ToLowerInvariant()
if (-not $nameCounts.ContainsKey($name)) { $nameCounts[$name] = 0 }
$nameCounts[$name]++
}
$report = foreach ($machine in $machines) {
$name = ([string]$machine.computerDnsName).Trim().TrimEnd('.').ToLowerInvariant()
$shortName = ($name -split '\.')[0]
$inScope = $false
foreach ($suffix in $DnsSuffixes) {
$domain = $suffix.Trim().Trim('.').ToLowerInvariant()
if ($domain -and $name.EndsWith('.' + $domain)) { $inScope = $true }
}
$last = [datetimeoffset]::MinValue
$validDate = [datetimeoffset]::TryParse([string]$machine.lastSeen, [ref]$last)
$adPresent = $adNames.Contains($name) -or $adShortNames.Contains($shortName)
$decision = if ($machine.osPlatform -notlike 'Windows*' -or -not $inScope) {
'ReviewOutsideScope'
} elseif (-not $validDate) {
'ReviewMissingTimestamp'
} elseif ($adPresent) {
'KeepADPresent'
} elseif ($last -ge $cutoff -or $machine.healthStatus -ne 'Inactive') {
'ReviewSensorState'
} elseif ($machine.riskScore -notin @('None','Informational','Low')) {
'ReviewRisk'
} else {
'CandidateReview'
}
[pscustomobject]@{
DeviceId = $machine.id
ComputerDnsName = $name
AadDeviceId = $machine.aadDeviceId
LastSeenUtc = if ($validDate) { $last.ToUniversalTime().ToString('o') } else { '' }
HealthStatus = $machine.healthStatus
RiskScore = $machine.riskScore
ADPresent = $adPresent
SameNameRecords = $nameCounts[$name]
Decision = $decision
CutoffUtc = $cutoff.ToString('o')
CollectedAtUtc = $now.ToString('o')
}
}
# No partial output if a domain lookup or API page fails.
$report
}
# Replace with one reachable DC per relevant domain and explicit DNS suffixes.
$inventoryOptions = @{
Token = $token
AdServers = @('dc01.corp.example.com')
DnsSuffixes = @('corp.example.com')
InactiveDays = 30
MinimumAdCount = 100 # Replace with an approved floor for YOUR environment.
}
$report = @(Get-MdeRetirementReport @inventoryOptions)
$report | Group-Object Decision | Select-Object Name,Count
# Inspect CSV fields as text; don't enable formulas from imported inventory data.
$report | Export-Csv './MDE-retirement-review.csv' -NoTypeInformation -Encoding utf8BOMRead the report: a candidate is not an authorization
Fictional example: PC-042.corp.example.com was reimaged and now has two MDE DeviceIds. Its AD account still exists. The report retains both records; the team must identify the current installation before excluding the old one. This false negative is preferable to automatically excluding an operational endpoint.
Another example: an Entra-only laptop retains an old DNS suffix but no longer has an AD account. It could become CandidateReview. Check Entra, Intune, the hardware inventory, the owner and the retirement ticket. Absence from a single source is insufficient. The report does not merge identities or prove physical disposal.
Before each batch, review open alerts and incidents, spare equipment, extended leave, critical servers and merged records. Even Low risk is not approval. SameNameRecords flags ambiguity, not a proven relationship between records. Treat the CSV as sensitive internal inventory and retain it with the change ticket.
| Decision | Next step |
|---|---|
| KeepADPresent | The AD object or a matching short name exists. Retain it and investigate duplicates separately. |
| ReviewOutsideScope | Non-Windows system, incomplete name or out-of-scope suffix. Consult the responsible inventory. |
| ReviewMissingTimestamp | Unusable timestamp. Verify the data before making a decision. |
| ReviewSensorState | Recent report or a state other than Inactive. Check sensor health and connectivity. |
| ReviewRisk | Medium, high or unrecognized risk. Obtain security review before cleanup. |
| CandidateReview | Missing from AD, old report and Inactive state. Confirm retirement against other sources. |
Tag one approved record through the API
The RetirementReview tag makes review records easy to find; it does not exclude them. First verify that no device group, automation rule or policy uses this tag for targeting. Tags can have indirect effects in environments that use them to scope security operations.
This block requires a recent report, a manually selected DeviceId and a ticket. It rereads the device before writing, rejects changed states and leaves other tags intact. Acquire writeToken through the separate tagging identity using the earlier authentication method. The sample invocation is a simulation using -WhatIf; the function also makes no changes without -Apply.
Start with one approved test record. For batches, keep a per-DeviceId log, cap the volume and reread state after uncertain responses before retrying. This demonstration is neither a production job scheduler nor a transaction spanning AD and MDE.
function Add-MdeRetirementReviewTag {
[CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')]
param(
[Parameter(Mandatory)][securestring]$Token,
[Parameter(Mandatory)][psobject]$Row,
[Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$Ticket,
[switch]$Apply
)
$ErrorActionPreference = 'Stop'
$id = [string]$Row.DeviceId
if ($id -notmatch '^[a-fA-F0-9]{40}$') { throw 'Unexpected MDE ID format; review manually.' }
if ($Row.Decision -ne 'CandidateReview') { throw 'Not a review candidate.' }
$age = [datetimeoffset]::UtcNow - [datetimeoffset]$Row.CollectedAtUtc
if ($age.TotalMinutes -lt 0 -or $age.TotalMinutes -gt 60) {
throw 'Rerun the full inventory before proceeding.'
}
$read = @{
Uri = "https://api.security.microsoft.com/api/machines/$id"
Method = 'Get'; Authentication = 'Bearer'; Token = $Token
MaximumRedirection = 0; ErrorAction = 'Stop'
}
$current = Invoke-RestMethod @read
$name = ([string]$current.computerDnsName).Trim().TrimEnd('.').ToLowerInvariant()
$last = [datetimeoffset]::MinValue
$valid = [datetimeoffset]::TryParse([string]$current.lastSeen, [ref]$last)
if ($current.id -ne $id -or $name -ne $Row.ComputerDnsName -or -not $valid -or
$last -ge [datetimeoffset]$Row.CutoffUtc -or $current.healthStatus -ne 'Inactive' -or
$current.riskScore -notin @('None','Informational','Low')) {
throw 'Device state changed or is incomplete; stop and investigate.'
}
$tag = 'RetirementReview'
if (@($current.machineTags) -contains $tag) {
return [pscustomobject]@{ DeviceId=$id; Result='AlreadyTagged'; Ticket=$Ticket }
}
if (-not $Apply) {
return [pscustomobject]@{ DeviceId=$id; Result='PreviewOnly'; Ticket=$Ticket }
}
if ($PSCmdlet.ShouldProcess("$name [$id] / $Ticket", "Add $tag tag only")) {
$write = @{
Uri = "https://api.security.microsoft.com/api/machines/$id/tags"
Method = 'Post'; Authentication = 'Bearer'; Token = $Token
ContentType = 'application/json'
Body = (@{ Value=$tag; Action='Add' } | ConvertTo-Json -Compress)
MaximumRedirection = 0; ErrorAction = 'Stop'
}
# No automatic POST retries: re-read the device if the outcome is uncertain.
$null = Invoke-RestMethod @write
[pscustomobject]@{ DeviceId=$id; Result='Tagged'; Ticket=$Ticket }
}
}
# After human approval, select ONE exact ID from a freshly generated report.
$approvedId = '<APPROVED-40-CHARACTER-MDE-ID>'
$selected = @($report | Where-Object DeviceId -eq $approvedId)
if ($selected.Count -ne 1) { throw 'Expected exactly one approved record.' }
$tagOptions = @{
Token = $writeToken # SecureString from a separate, authorized writer identity.
Row = $selected[0]
Ticket = 'CHG-EXAMPLE-001'
}
Add-MdeRetirementReviewTag @tagOptions -Apply -WhatIf
# Only after approval, replace -WhatIf with -Confirm to perform the tag operation.Exclude confirmed obsolete records without inventing an API
In Assets → Devices, select the approved records, choose Exclude, and provide a reason such as Duplicate device or Device doesn’t exist with the change ticket. Bulk exclusion is available; Microsoft allows up to 10 hours for propagation. To reverse it, use Exclusion details → Stop exclusion; data may take up to 8 hours to return.
Microsoft's public documentation describes this exclusion in the portal, but not a stable public endpoint equivalent to that button. Update machine does not document writes to healthStatus or IsExcluded. This guide therefore uses neither invented PATCH requests nor internal browser-captured APIs. If Microsoft publishes a dedicated API, review its contract and permissions before automating that step.
Offboarding is a separate security operation
For a reachable device with approved retirement, the offboarding API avoids distributing a local package. It requires Machine.Offboard. The HTTP example illustrates the request contract; do not add it to the report’s candidate loop. A physically absent device cannot execute a command merely because its old record still exists.
On Windows, Microsoft notes that the API stops the sensor service without clearing registry onboarding information as the local script does. Review applicable GPO, Intune and other deployment mechanisms. Do not apply conflicting onboarding and offboarding instructions at the same time.
The response contains a MachineAction: retain its ID and check the final status. An accepted request is not proof of completion. Do not blindly repeat a POST after a network timeout. A complete retirement workflow also considers data recovery, the local administrator password, management removal and directory cleanup according to approvals and dependencies; this article does not execute those steps.
POST https://api.security.microsoft.com/api/machines/{approved-device-id}/offboard
Authorization: Bearer {token-with-Machine.Offboard-permission}
Content-Type: application/json
{"Comment":"Approved retirement - CHG-EXAMPLE-001"}Validate with KQL while respecting retention windows
This Advanced Hunting query returns the latest available event per DeviceId for excluded or tagged devices. It also exposes merged identifiers that help investigate reimaging. Appropriate Advanced Hunting permissions and licensing are required; this capability is not included in Defender for Business.
The query covers only the available data window, normally 30 days in native Advanced Hunting. An old record with no event in that window will not appear. Use inventory and portal details for final validation of older devices; KQL is not a guaranteed 180-day device inventory.
// Select an appropriate time range in Advanced Hunting.
DeviceInfo
| summarize arg_max(Timestamp, *) by DeviceId
| where IsExcluded == true
or DeviceManualTags contains "RetirementReview"
| project Timestamp, DeviceId, DeviceName, AadDeviceId,
SensorHealthState, IsExcluded, ExclusionReason,
MergedToDeviceId, MergedDeviceIds
| order by Timestamp descBuild a repeatable review process, not a bulk deletion job
The examples’ 30-day and one-hour thresholds are proposed safeguards, not Microsoft requirements. Adjust them to your loan, repair and replacement cycles. Validate the examples in a test environment: publication is not validation against your tenant, permissions or domains.
The goal is not an empty dashboard. It is an inventory where every retained, excluded or retired record has an understandable reason, evidence and an owner. To include this process in a broader review, continue with the AD assessment guide or have the dependencies scoped before automating writes.
- First run: report only; verify domains, inventory counts and false positives.
- First week: obtain asset-owner review and select a few exact DeviceIds with change tickets.
- After approval: tag, exclude or offboard according to the goal, never all three automatically.
- Every run: alert on unreachable domains, unexpected inventory drops and API failures. Empty results are not an all-clear.
- After changes: check active records, exclusions and returning devices. Restore scope for devices that become relevant again.