Overview
In non-persistent Softdrive environments (Fleets), virtual desktops may register as Microsoft Entra ID (Azure AD) joined devices each time they are deployed. :contentReference[oaicite:0]{index=0}
If these device objects are not removed automatically, the Entra tenant can quickly accumulate stale device entries (“device sprawl”).
This article explains how to automatically delete Microsoft Entra device objects when a Softdrive Fleet VM shuts down.
This solution:
- Detects whether the VM is Azure AD joined
- Reads the local Device ID
- Resolves the Entra Object ID through Microsoft Graph
- Deletes the device object automatically
- Writes actions to a log file for auditing and troubleshooting
- Can be embedded directly into the Fleet Golden Image
Applies To
- Softdrive Fleets
- Non-persistent Virtual Desktops
- Microsoft Entra ID (Azure AD)
- Microsoft Graph API
- Golden Images
- Windows Virtual Machines
Prerequisites
- Global Administrator or Application Administrator access in Entra
- Microsoft Graph Application permission:
Device.ReadWrite.All - Admin consent granted in Entra
- Softdrive Fleet environment
- Access to the Golden Image before publishing
- PowerShell enabled on the VM
Index
- Register an App in Microsoft Entra
- Create the Cleanup Script
- Create the Folder Structure
- Create the Scheduled Task
- Validation
- Troubleshooting
1) Register an App in Microsoft Entra
The cleanup script requires an Entra application with Microsoft Graph permissions to delete device objects automatically. :contentReference[oaicite:1]{index=1}
- Open:
https://entra.microsoft.com - Navigate to:
Identity → Applications → App registrations - Click:
New registration - Name the application:
VDI-AutoCleanup - Select:
Accounts in this organizational directory only - Leave:
blankRedirect URI - Click:
Register
Capture Required Values
After registration, copy the following values:
- Application (client) ID → ClientId
- Directory (tenant) ID → TenantId
Create a Client Secret
- Open:
Certificates & secrets - Click:
New client secret - Copy the:
(not the Secret ID)Secret VALUE
Assign Microsoft Graph Permissions
- Open:
API permissions - Click:
Add a permission → Microsoft Graph → Application permissions - Add:
Device.ReadWrite.All - Click:
Grant admin consent
2) Create the Cleanup Script
Create a PowerShell script named:
EntraCleanup.ps1The script will:
- Check whether the VM is Azure AD joined
- Read the local Device ID
- Authenticate against Microsoft Graph
- Locate the Entra Object ID
- Delete the device automatically during shutdown
- Write actions into a log file
# =========================
# CONFIG
# =========================
$TenantId = "YOUR_TENANT_ID"
$ClientId = "YOUR_CLIENT_ID"
$ClientSecret = "YOUR_CLIENT_SECRET"
# Logging
$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 {
return (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)"
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 or placeholder values detected. Set TenantId, ClientId, and 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 "Device is 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 te script, update the configuration section with your tenant values:
$TenantId = "YOUR_TENANT_ID"
$ClientId = "YOUR_CLIENT_ID"
$ClientSecret = "YOUR_CLIENT_SECRET"3) Create the Folder Structure
The script must be embedded into the Golden Image before the Fleet template is published. :contentReference[oaicite:3]{index=3}
This ensures all deployed Fleet machines inherit the cleanup mechanism automatically.
Create the Folder
C:\ProgramData\VDI-AutoCleanupPlace the Script Here
C:\ProgramData\VDI-AutoCleanup\EntraCleanup.ps1Once embedded in the Golden Image:
- All future Fleet VMs inherit the cleanup automation
- Device objects are removed automatically during shutdown
- Entra device sprawl is minimized
4) Create the Scheduled Task
The cleanup script should execute automatically during shutdown events. :contentReference[oaicite:4]{index=4}
Open:
Task SchedulerThen click:
Create TaskGeneral 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 - If already running:
Do not start a new instance
Validation
To confirm the cleanup works correctly:
- Open:
Entra → Devices - Search for the Fleet VM name
- Confirm the old device object is removed after shutdown
Troubleshooting
- 401 Unauthorized
Verify:- TenantId
- ClientId
- ClientSecret
- Secret VALUE (not Secret ID)
- 403 Forbidden
Confirm:
is assigned as an Application Permission and admin consent is granted.Device.ReadWrite.All - No log file created
Verify:- Scheduled task runs as SYSTEM
- Script path is correct
- Tenant credentials are valid
- Device still shows AzureAdJoined = YES
Expected behavior.
The script removes the Entra device object but does not unjoin the local VM from Azure AD.
Expected Result
When a Softdrive Fleet VM shuts down:
- The Entra device object is deleted automatically
- Stale Azure AD devices do not accumulate
- Fleet deployments remain clean and manageable
- Entra device sprawl is minimized
Title for Search Engine
Automatically Delete Entra Device Objects in Softdrive Fleets
Description for Search Engine
Learn how to automatically remove Microsoft Entra ID device objects from Softdrive Fleet virtual machines using PowerShell, Microsoft Graph, and scheduled shutdown cleanup tasks.
Tags
Softdrive, Entra ID, Azure AD, Fleet, VDI, Device Cleanup, Microsoft Graph, Golden Image, PowerShell