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
- Register an App in Microsoft Entra
- Create the EntraCleanup.ps1 Script
- Create the Folder Structure
- Create the Scheduled Task
- Validation
- Troubleshooting
1) Register an App in Microsoft Entra
- Go to Microsoft Entra admin center (
https://entra.microsoft.com). - Navigate to Identity → Applications → App registrations.
- Click New registration.
- Name the app: VDI-AutoCleanup.
- Select: Accounts in this organizational directory only.
- Leave Redirect URI blank.
- Click Register.
Capture required values
After registration, copy these values (you will paste them into the script later):
- Application (client) ID →
ClientId - Directory (tenant) ID →
TenantId
Create a client secret
- Open Certificates & secrets.
- Click New client secret.
- Copy the Secret VALUE (it is only shown once).
Assign Microsoft Graph permission
- Open API permissions.
- Click Add a permission → Microsoft Graph → Application permissions.
- Select
Device.ReadWrite.All. - 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-AutoCleanupPlace the script in this location:
C:\ProgramData\VDI-AutoCleanup\EntraCleanup.ps14) 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.exeAdd arguments:
-NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\VDI-AutoCleanup\EntraCleanup.ps1"Start in:
C:\ProgramData\VDI-AutoCleanupSettings
- 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
ClientSecretandTenantIdare correct, and that you used the Secret VALUE. - 403 Forbidden → Confirm
Device.ReadWrite.Allis 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.