Common scenario.
You have a device that has been enrolled and actively used. You reset or re-image it to test something from a fresh start.
After re-enrolling the device, you check Intune. Oh wow, there are two entries : the original and a newly created one. Each has a different object ID, even though they refer to the same device.
Same thing happens when an unique resetted device is re-enrolled for a new user. And again for a new user. And again. And again. The device keeps getting enrolled, but proper unenrollment never happens.
This leads to this situation, like these two examples searching by their serial numbers :


After investigating, we found that the customer had over 12,000 duplicated devices.
While these inactive duplicates will eventually be removed based on clean-up rules, they still pose a problem.
Power BI data becomes inaccurate. It’s difficult to report accurate update deployment metrics when a significant portion of the fleet consists of inactive, duplicated devices. Hard to explain to management that updates are successfully deployed to X%of the fleet when Y% of the data reflects devices that no longer exist.
So, let’s do some cleanup !
What defines a duplicated device ?
Before removing, let’s try to understand what is a duplicated device.
Device name is not an option in most cases. Especially for mobile devices with a random part in it. Also, i have my own opinion regarding device naming convention, being not as important as we may think.
Serial number is a good start. We can consider multiple identical serial numbers being an unique device. The thing is, sometime, serial number is not uploaded for some reasons (Android device administrator enrollment, enrollment phase not completed, specific hardware, …).
IMEI number is great also. IMEI nimber is a unique property. There are not two different devices with a same IMEI number in the world.
WIFI Mac Adress ? Could be useful, but there’s a catch. It’s a read-only property, and so far, I haven’t found a way to filter it in a Graph query.
OK now we do know how to recognized duplicated devices. Let’s do some scripting.
Removing logic
Based on these definitions, we can consider that if i request a device filtering on serial number or imei and i get multiple results then one of them is a “real” device and the others are duplicated entries.
I also consider that the “real” device is the one with the most recent lastsyncdate.
Powershell time
Connect-Mggraph with a registered application with the proper permission to read/write Managed devices. Also, we will remove the device from Entra, so Read/Write devices in Entra ID is also essential.
#Initialize variables : Please use Vault to store the password or certificate
$global:tenant = "xxxxxxx"
$global:clientId = "xxxxxxx"
$global:clientSecret = "xxxxxxxx"
$SecuredPasswordPassword = ConvertTo-SecureString -String $clientSecret -AsPlainText -Force
$ClientSecretCredential = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList $clientId, $SecuredPasswordPassword
Connect-MgGraph -TenantId $tenant -ClientSecretCredential $ClientSecretCredential
Let’s start with iOS devices. Let’s get all of them by filtering on the operating system :
# Create an array which will contains all ios devices
$allDevices = @()
$duplicateDevices = @()
$devicesarray = @{}
# Initialize the nextLink variable
$nextLink = "https://graph.microsoft.com/beta/devicemanagement/manageddevices?`$filter=operatingsystem eq 'iOS'"
# Fetch all devices
while (![string]::IsNullOrEmpty($nextLink)) {
$response = Invoke-MgGraphRequest -Method GET -Uri "$nextLink"
$allDevices += $response.value # Add this page's devices
$nextLink = $response.'@odata.nextLink' # Get the next page's URL
Write-Host $nextLink
}
Now we have all devices stored in $alldevices array (the other tables will be used later), let’s parse them of all and retrieve their serial number or imei properties. $identifier will be used later to figure out what kind of filter i need to use in my future query :
foreach ($device in $allDevices) {
$imei = $device.imei
$serialNumber = $device.serialNumber
$identifier = $null
Let’s continue. If there is an IMEI, i consider the filter will be based on the IMEI number. If i have no IMEI, i’ll take the serial number. If i still have nothing then i skip the device because this is the only properties i can use to filter devices and i don’t want to take my chances removing real devices.
if ($imei -ne $null -and $imei -ne "") {
$identifier = $imei
} elseif ($serialNumber -ne $null -and $serialNumber -ne "" -and $serialNumber -ne "0") {
$identifier = $serialNumber
} else {continue}
Initially, I considered using : [string]::IsNullOrEmpty(). However, I encountered strange cases where serial numbers were set to "0", “SerialNumber”, or “SystemSerial”, making this approach unreliable.
To avoid false positives, I take a safer approach using an array called $duplicateDevices. This table contains only the devices I have positively identified as duplicates.
To determine if the device is indeed a duplicated devices, i say :
1- If the identifier already exists in $devicesArray, the device is definitely a duplicate. I add it to $duplicateDevices.
2- If the identifier is not in $devicesArray, i add it immediately, so future iterations can check for duplicated against it.
See below :
if ($identifier -ne $null) {
if ($devicesarray.ContainsKey($identifier)) {
Write-Host "Duplicate found for identifier: $identifier"
$duplicateDevices += $device
} else {
$devicesarray[$identifier] = $device
}
}
}
I prefer to play it safe with this approach. I don’t want to parse my $alldevices array directly as removing devices is irreversible. Later, you will see i’ll parse my $duplicateddevices instead of $alldevices. Call me a coward haha but hey, better safe than sorry !
So now, all duplicated devices are stored in my $duplicateddevices eheh.
You can imagine another end if the $identifier is null like a write-warning or something of course.
I also found it usefull to do a $duplicatedDevices.count or inspect this table during my initial tests and see what can be wrong with my logic. You’ll be surprised.
Let’s remove some devices
Before proceeding with deletion, here’s a crucial disclaimer : Do not run this script in production without thorough testing.
I strongly recommend running the script first with the “Invoke-MggraphRequest -method Delete” lines commented out. Instead, print the IDS and device names as suggested in the Test Before Remove section. This ensures you see exactly what would be deleted before taking any irreversible action.
Now disclaimer is over, let’s begin.
We parse each duplicate device and again, we get the serial and the imei number. As i said, i’m playing it very carefull but if you like to live in the danger, lets do this single foreach loop by parsing $alldevices instead 🙂
If imei is empty, then filter is serial. If its not empty, then the filter will be imei.
foreach ($duplicate in $duplicateDevices) {
Write-Warning $duplicate.deviceName
$serial = $duplicate.serialNumber
$imei = $duplicate.imei
if ([string]::IsNullOrEmpty($imei)) {
$filterValue = $serial
$filterField = "serialNumber"
} else {
$filterValue = $imei
$filterField = "imei"
}
$uri = "https://graph.microsoft.com/beta/devicemanagement/manageddevices?`$filter=$filterField eq '$filterValue'"
$response = Invoke-MgGraphRequest -uri $uri -Method get
Time to build the uri wich is then used to request the devices.
$uri = "https://graph.microsoft.com/beta/devicemanagement/manageddevices?`$filter=$filterField eq '$filterValue'"
$response = Invoke-MgGraphRequest -uri $uri -Method get
By requesting this $uri, i get multiple results right ? That’s the definition of duplicated devices.
The thing is, imagine i have 10 results for a single device. I don’t want to remove 10 of them. One of these must be an actual “real” and active device right ? So i sort all of the results by lastsyncdate and i skip the most recent one.
$duplicatessorted = $response.value | Sort-Object lastsyncdatetime -Descending | Select-Object -Skip 1
For each duplicated which have been sorted, i need the intune id to remove the device in Intune. But i also need the azuread device id, because remember, i want also to remove the device in Entra
foreach ($oneduplicatesorted in $duplicatessorted) {
$intuneid = $oneduplicatesorted.id
$azureaddeviceid = $oneduplicatesorted.azureADDeviceId
Now i have the ids, time for delete first the intune object :
Invoke-MgGraphRequest -Method DELETE -Uri "https://graph.microsoft.com/beta/devicemanagement/manageddevices/$intuneid"
Alright, intune object is deleted. Time for searching the entra object and then deleted.
To retrieve the entra object, i use the azure ad device id to filter my request in entra uri which is /devices instead of /devicemanagement/manageddevices which is Intune related.
$urientra = "https://graph.microsoft.com/beta/devices?`$filter=deviceid eq '$azureaddeviceid'"
$request = Invoke-MgGraphRequest -method get -uri $urientra
Now i use the object id in Entra using the request value id and build my uri. Run the delete command and here we go.
$objectid = $request.value.id
Invoke-MgGraphRequest -method DELETE -uri "https://graph.microsoft.com/beta/devices/$objectid"
}
}
Et voilà !
Test before remove
To test the script without removing the devices, i found it usefull to comment the “delete invoke-mggraphrequest” and display the intune id and entra object id, with the device name for each device parsed.
foreach ($duplicate in $duplicateDevices) {
Write-Warning $duplicate.deviceName
$serial = $duplicate.serialNumber
$imei = $duplicate.imei
if ([string]::IsNullOrEmpty($imei)) {
$filterValue = $serial
$filterField = "serialNumber"
} else {
$filterValue = $imei
$filterField = "imei"
}
$uri = "https://graph.microsoft.com/beta/devicemanagement/manageddevices?`$filter=$filterField eq '$filterValue'"
$response = Invoke-MgGraphRequest -uri $uri -Method get
$duplicatessorted = $response.value | Sort-Object lastsyncdatetime -Descending | Select-Object -Skip 1
foreach ($duplicatesorted in $duplicatessorted) {
$intuneid = $duplicatesorted.id
$azureaddeviceid = $duplicatesorted.azureADDeviceId
# Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/beta/devicemanagement/manageddevices/$intuneid" -Method DELETE
$urientra = "https://graph.microsoft.com/beta/devices?`$filter=deviceid eq '$azureaddeviceid'"
$request = Invoke-MgGraphRequest -method get -uri $urientra
$objectid = $request.value.id
Write-Warning "$intuneid will be deleted in intune, $objectid will be deleted in entra"
# Invoke-MgGraphRequest -method DELETE -uri "https://graph.microsoft.com/beta/devices/$objectid"
$counting++
}
You get something like :

This logic is applied as it is for Android and iOS.
For Windows, IMEI number is not relevant so i totally rely on the serial number. Here is the full script for windows devices :
# Use a vault to store the certificate or password for secured usage
$global:tenant = "xxxxx"
$global:clientId = "xxxxx"
$global:clientSecret = "xxx"
$SecuredPasswordPassword = ConvertTo-SecureString -String $clientSecret -AsPlainText -Force
$ClientSecretCredential = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList $clientId, $SecuredPasswordPassword
Connect-MgGraph -TenantId $tenant -ClientSecretCredential $ClientSecretCredential
###################################################################################################################
###################################################################################################################
# Initialize collections
$allDevices = @()
$duplicateDevices = @()
$deviceSerials = @{}
# Initialize the nextLink variable
$nextLink = "https://graph.microsoft.com/beta/devicemanagement/manageddevices?`$filter=operatingsystem eq 'Windows'"
# Fetch all devices
while (![string]::IsNullOrEmpty($nextLink)) {
$response = Invoke-MgGraphRequest -Method GET -Uri "$nextLink"
$allDevices += $response.value # Add this page's devices
$nextLink = $response.'@odata.nextLink' # Get the next page's URL
Write-Host $nextLink
}
# Check for duplicate devices based on serial number
foreach ($device in $allDevices) {
$serialNumber = $device.serialNumber
$identifier = $null
if ($serialNumber -ne $null -and $serialNumber -ne "" -and $serialNumber -ne "serialnumber" -and $serialNumber -ne "0" -and $serialNumber -ne "Defaultstring") {
$identifier = $serialNumber
} else {
continue
}
if ($identifier -ne $null) {
if ($deviceSerials.ContainsKey($identifier)) {
$duplicateDevices += $device
} else {
$deviceSerials[$identifier] = $device
}
}
}
$howmany = $duplicateDevices.count
Write-Warning "$howmany devices have one or more duplicated entries. Removal in progress"
# Process duplicate devices
foreach ($duplicate in $duplicateDevices) {
$serial = $duplicate.serialNumber
if ([string]::IsNullOrEmpty($serialNumber)) {
continue
} else {
$filterValue = $serial
$filterField = "serialNumber"
}
$uri = "https://graph.microsoft.com/beta/devicemanagement/manageddevices?`$filter=$filterField eq '$filterValue'"
$response = Invoke-MgGraphRequest -uri $uri -Method get
$duplicatessorted = $response.value | Sort-Object lastsyncdatetime -Descending | Select-Object -Skip 1
foreach ($duplicatesorted in $duplicatessorted) {
$intuneid = $duplicatesorted.id
$azureaddeviceid = $duplicatesorted.azureADDeviceId
Write-Warning "$intuneid will be removed from intune, $azureaddeviceid will be removed from entra"
#Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/beta/devicemanagement/manageddevices/$intuneid" -Method DELETE
$urientra = "https://graph.microsoft.com/beta/devices?`$filter=deviceid eq '$azureaddeviceid'"
$request = Invoke-MgGraphRequest -method get -uri $urientra
$objectid = $request.value.id
#Invoke-MgGraphRequest -method DELETE -uri "https://graph.microsoft.com/beta/devices/$objectid"
}
}
All scripts for each platform are stored in my personal github here.
Have a try and let me know your suggestions. I’ll work to make it easier to use in the next days, and make it cross platform.
Sharing is caring !
Hello,
I had a question about the script. At the top you indicate “Use a vault to secure register application usage”. Can you explain this? (or link to a page that talks about using a vault?)
LikeLike
Hi ! Using a vault is used to avoid putting the application secret or certificate in plain text inside the script. To avoid this, i suggest to create an Azure Vault and get the secret from here. You can follow this method : https://learn.microsoft.com/en-us/answers/questions/2007051/securely-storing-and-passing-azure-app-registratio
Let me know how it goes 🙂
LikeLike
Awesome, thank you! I have heard of Azure Vault before but never looked into it and I wasn’t sure if you were meaning that or something else local to the computer running the script.
LikeLike