Automatic Microsoft Entra Device Cleanup on Fleet computers reprovision

Overview

In non-persistent Softdrive environments (Fleets), virtual desktops may register as Microsoft Entra ID (Azure AD) joined devices each time they are deployed. If these device objects are not removed automatically, your tenant can quickly accumulate stale entries (“device sprawl”).


This article explains how to automatically delete Entra device objects when a VDI shuts down.

This solution will:

  • Detect whether the VM is AzureADJoined
  • Read the local DeviceId
  • Resolve the device’s Entra Object ID via Microsoft Graph
  • Delete the device object automatically
  • Write actions to a log file for auditing and troubleshooting
  • Add this script into the GoldenImage for the Fleet machine.

Prerequisites

  • Global Administrator or Application Administrator access in Entra.
  • App registration with Microsoft Graph Application permission: Device.ReadWrite.All
  • Admin consent granted for the tenant.

Index

  1. Register an App in Microsoft Entra
  2. Create the EntraCleanup.ps1 Script
  3. Create the Folder Structure
  4. Create the Scheduled Task
  5. Validation
  6. Troubleshooting

1) Register an App in Microsoft Entra

  1. Go to Microsoft Entra admin center (https://entra.microsoft.com).
  2. Navigate to Identity → Applications → App registrations.
  3. Click New registration.
  4. Name the app: VDI-AutoCleanup.
  5. Select: Accounts in this organizational directory only.
  6. Leave Redirect URI blank.
  7. Click Register.

Capture required values

After registration, copy these values (you will paste them into the script later):

  • Application (client) IDClientId
  • Directory (tenant) IDTenantId

Create a client secret

  1. Open Certificates & secrets.
  2. Click New client secret.
  3. Copy the Secret VALUE (it is only shown once).

Assign Microsoft Graph permission

  1. Open API permissions.
  2. Click Add a permissionMicrosoft GraphApplication permissions.
  3. Select Device.ReadWrite.All.
  4. Click Grant admin consent.

2) Create the EntraCleanup.ps1 Script

Create a PowerShell script named EntraCleanup.ps1 with the following content:


# =========================
# CONFIG
# =========================
$TenantId     = "xxx"
$ClientId     = "xxx"
$ClientSecret = "xxx"


# Logging (ONE place)
$LogDir  = "C:\ProgramData\VDI-AutoCleanup"
$LogFile = Join-Path $LogDir "cleanup.log"


function Write-Log {
  param(
    [Parameter(Mandatory)] [string] $Message,
    [ValidateSet("INFO","WARN","ERROR")] [string] $Level = "INFO"
  )
  if (-not (Test-Path $LogDir)) { New-Item -Path $LogDir -ItemType Directory -Force | Out-Null }
  $ts = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss")
  Add-Content -Path $LogFile -Value "[$ts] [$Level] $Message"
}


function Get-HttpErrorDetails {
  param([Parameter(Mandatory)] $ErrorRecord)


  $status = $null
  $body   = $null


  try {
    if ($ErrorRecord.Exception.Response) {
      $status = [int]$ErrorRecord.Exception.Response.StatusCode
      $stream = $ErrorRecord.Exception.Response.GetResponseStream()
      if ($stream) {
        $reader = New-Object System.IO.StreamReader($stream)
        $body = $reader.ReadToEnd()
      }
    }
  } catch { }


  return @{ Status = $status; Body = $body }
}


function Get-LocalJoinInfo {
  $out = & dsregcmd /status 2>$null
  if (-not $out) { return $null }


  $azureLine  = $out | Select-String -Pattern '^\s*AzureAdJoined\s*:\s*' | Select-Object -First 1
  $deviceLine = $out | Select-String -Pattern '^\s*DeviceId\s*:\s*'      | Select-Object -First 1


  $azureJoined = $false
  if ($azureLine) {
    $val = (($azureLine.ToString() -split ":\s*", 2)[1]).Trim()
    $azureJoined = ($val -eq "YES")
  }


  $deviceId = $null
  if ($deviceLine) {
    $deviceId = (($deviceLine.ToString() -split ":\s*", 2)[1]).Trim()
    try { [void][Guid]::Parse($deviceId) } catch { $deviceId = $null }
  }


  return @{ AzureAdJoined = $azureJoined; DeviceId = $deviceId }
}


function Get-GraphToken {
  param(
    [Parameter(Mandatory)] [string] $TenantId,
    [Parameter(Mandatory)] [string] $ClientId,
    [Parameter(Mandatory)] [string] $ClientSecret
  )


  $tokenUri = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"
  $body = @{
    client_id     = $ClientId
    scope         = "https://graph.microsoft.com/.default"
    client_secret = $ClientSecret
    grant_type    = "client_credentials"
  }


  try {
    (Invoke-RestMethod -Method Post -Uri $tokenUri -Body $body -TimeoutSec 15 -ErrorAction Stop).access_token
  } catch {
    $d = Get-HttpErrorDetails $_
    Write-Log -Level "ERROR" -Message ("Token acquisition failed: {0} | HTTP={1} | Body={2}" -f $_.Exception.Message, $d.Status, $d.Body)
    return $null
  }
}


function Get-DeviceObjectIdFromDeviceId {
  param(
    [Parameter(Mandatory)] [string] $AccessToken,
    [Parameter(Mandatory)] [string] $DeviceId
  )


  $headers = @{ Authorization = "Bearer $AccessToken" }
  $lookupUri = "https://graph.microsoft.com/v1.0/devices?`$filter=deviceId eq '$DeviceId'&`$select=id,displayName,deviceId"


  try {
    $resp = Invoke-RestMethod -Method Get -Uri $lookupUri -Headers $headers -TimeoutSec 15 -ErrorAction Stop


    if (-not $resp.value -or $resp.value.Count -eq 0) { return $null }
    if ($resp.value.Count -gt 1) {
      Write-Log -Level "WARN" -Message "Multiple devices matched deviceId=$DeviceId. Using first result."
    }


    return $resp.value[0].id
  } catch {
    $d = Get-HttpErrorDetails $_
    Write-Log -Level "ERROR" -Message ("Lookup failed for deviceId=$DeviceId : {0} | HTTP={1} | Body={2}" -f $_.Exception.Message, $d.Status, $d.Body)
    return $null
  }
}


function Delete-DeviceByObjectId {
  param(
    [Parameter(Mandatory)] [string] $AccessToken,
    [Parameter(Mandatory)] [string] $ObjectId
  )


  $headers = @{ Authorization = "Bearer $AccessToken" }
  $deleteUri = "https://graph.microsoft.com/v1.0/devices/$ObjectId"


  try {
    Invoke-RestMethod -Method Delete -Uri $deleteUri -Headers $headers -TimeoutSec 15 -ErrorAction Stop
    return $true
  } catch {
    $d = Get-HttpErrorDetails $_
    Write-Log -Level "ERROR" -Message ("Delete failed for objectId=$ObjectId : {0} | HTTP={1} | Body={2}" -f $_.Exception.Message, $d.Status, $d.Body)
    return $false
  }
}


# =========================
# MAIN
# =========================
Write-Log -Message "Starting task as $env:USERNAME (PID=$PID). ComputerName=$($env:COMPUTERNAME)"


# Stronger config validation (handles null/empty)
if ([string]::IsNullOrWhiteSpace($TenantId) -or
    [string]::IsNullOrWhiteSpace($ClientId) -or
    [string]::IsNullOrWhiteSpace($ClientSecret) -or
    $TenantId -match "^YOUR_" -or $ClientId -match "^YOUR_" -or $ClientSecret -match "^YOUR_") {
  Write-Log -Level "ERROR" -Message "Config missing/placeholder. Set TenantId/ClientId/ClientSecret."
  exit 1
}


$join = Get-LocalJoinInfo
if (-not $join) { Write-Log -Level "WARN" -Message "dsregcmd returned no data. Exiting."; exit 0 }
if (-not $join.AzureAdJoined) { Write-Log -Level "WARN" -Message "NOT AzureAdJoined. Exiting."; exit 0 }
if (-not $join.DeviceId) { Write-Log -Level "WARN" -Message "Could not parse local DeviceId. Exiting."; exit 0 }


Write-Log -Message "Local AzureAdJoined=YES, DeviceId=$($join.DeviceId)"


$token = Get-GraphToken -TenantId $TenantId -ClientId $ClientId -ClientSecret $ClientSecret
if (-not $token) { exit 2 }


$objectId = Get-DeviceObjectIdFromDeviceId -AccessToken $token -DeviceId $join.DeviceId
if (-not $objectId) { Write-Log -Level "WARN" -Message "No device object found in Graph for deviceId=$($join.DeviceId). Exiting."; exit 0 }


Write-Log -Message "Resolved Graph ObjectId=$objectId"


$ok = Delete-DeviceByObjectId -AccessToken $token -ObjectId $objectId
if ($ok) { Write-Log -Message "Deleted Entra device object successfully. ObjectId=$objectId"; exit 0 }
else { exit 3 }

At the top of the script, update the configuration section with your tenant values:

$TenantId     = "YOUR_TENANT_ID"
$ClientId     = "YOUR_CLIENT_ID"
$ClientSecret = "YOUR_CLIENT_SECRET"

Important: Use the Secret VALUE (not the Secret ID).


3) Create the Folder Structure

The script and scheduled task must be added to the template (golden image) before publishing.

This ensures:

  • All deployed machines inherit the cleanup mechanism
  • Device objects are removed automatically at shutdown
  • Entra device sprawl is prevented across the entire fleet


In order to accomplish this create the following folder:

C:\ProgramData\VDI-AutoCleanup

Place the script in this location:

C:\ProgramData\VDI-AutoCleanup\EntraCleanup.ps1

4) Create the Scheduled Task

This task runs the cleanup script automatically during shutdown events, ensuring the Entra device object is removed without requiring user action.

Open Task Scheduler and click Create Task.

General tab

  • Name: Entra VDI Cleanup
  • Select: Run whether user is logged on or not
  • Select: Run with highest privileges
  • User account: SYSTEM

Trigger

  • Begin the task: On an event
  • Log: System
  • Source: User32
  • Event ID: 1074

Action

Program/script:

C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe

Add arguments:

-NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\VDI-AutoCleanup\EntraCleanup.ps1"

Start in:

C:\ProgramData\VDI-AutoCleanup

Settings

  • Disable: Stop the task if it runs longer than (do not enforce a time limit).
  • If the task is already running: Do not start a new instance.




Validation


Confirm in Entra:

  • Go to Entra → Devices
  • Search for the VM name and confirm the old device object is gone

Troubleshooting

  • 401 Unauthorized → Verify ClientSecret and TenantId are correct, and that you used the Secret VALUE.
  • 403 Forbidden → Confirm Device.ReadWrite.All is added as an Application permission and admin consent is granted.
  • No log file created → Confirm the task runs as SYSTEM, verify the script path is correct and confirm the tenandId, ClientId and ClientSecret are correct.
  • Device still shows AzureAdJoined = YES → Expected. The script deletes the Entra device object; it does not unjoin the local machine.