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. :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

  1. Register an App in Microsoft Entra
  2. Create the Cleanup Script
  3. Create the Folder Structure
  4. Create the Scheduled Task
  5. Validation
  6. 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}

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

Capture Required Values

After registration, copy the following values:

  • Application (client) ID → ClientId
  • Directory (tenant) ID → TenantId

Create a Client Secret

  1. Open:
    Certificates & secrets
  2. Click:
    New client secret
  3. Copy the:
    Secret VALUE
    (not the Secret ID)

Assign Microsoft Graph Permissions

  1. Open:
    API permissions
  2. Click:
    Add a permission → Microsoft Graph → Application permissions
  3. Add:
    Device.ReadWrite.All
  4. Click:
    Grant admin consent

2) Create the Cleanup Script

Create a PowerShell script named:

EntraCleanup.ps1

The 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-AutoCleanup

Place the Script Here

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

Once 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 Scheduler

Then 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
  • 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:
    Device.ReadWrite.All
    is assigned as an Application Permission and admin consent is granted.
  • 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