#Requires -Modules Microsoft.Graph.Authentication, Microsoft.Graph.Identity.DirectoryManagement, Microsoft.Graph.DeviceManagement
<#
.SYNOPSIS
Finds Windows devices joined to Entra ID (Azure AD) that are NOT enrolled in Intune.
.DESCRIPTION
Queries Microsoft Graph for all Windows devices in Entra ID (Azure AD Joined or Hybrid
Azure AD Joined), then compares against Intune-enrolled devices. Outputs a report of
devices present in Entra but missing from Intune.
Supports:
- Single tenant or multi-tenant (GDAP/CSP) via -TenantId
- Export to CSV
- Filtering by join type (AADJ, HAADJ, or both)
- Optional stale device filtering by last sign-in age
.PARAMETER TenantId
The target tenant ID or domain. Defaults to your home tenant if omitted.
.PARAMETER ExportCsv
Path to export results as a CSV file. If omitted, results are written to the console.
.PARAMETER JoinType
Filter by join type. Options: All, AADJ (Azure AD Joined only), HAADJ (Hybrid only).
Default: All
.PARAMETER ExcludeStaleDevices
If specified, devices with no sign-in activity in the last N days are excluded.
.PARAMETER StaleDaysThreshold
Number of days of inactivity to consider a device stale. Default: 90.
Only used when -ExcludeStaleDevices is set.
.EXAMPLE
# Run against your home tenant, output to console
.\Get-EntraNotInIntune.ps1
.EXAMPLE
# Run against a specific tenant (GDAP), export to CSV
.\Get-EntraNotInIntune.ps1 -TenantId "contoso.onmicrosoft.com" -ExportCsv "C:\Reports\EntraNotInIntune.csv"
.EXAMPLE
# AADJ only, exclude devices inactive for 60+ days
.\Get-EntraNotInIntune.ps1 -JoinType AADJ -ExcludeStaleDevices -StaleDaysThreshold 60
.NOTES
Required Graph API Permissions (Application or Delegated):
- Device.Read.All
- DeviceManagementManagedDevices.Read.All
Install modules if needed:
Install-Module Microsoft.Graph -Scope CurrentUser
#>
[CmdletBinding()]
param(
[Parameter()]
[string]$TenantId,
[Parameter()]
[string]$ExportCsv,
[Parameter()]
[ValidateSet("All", "AADJ", "HAADJ")]
[string]$JoinType = "All",
[Parameter()]
[switch]$ExcludeStaleDevices,
[Parameter()]
[int]$StaleDaysThreshold = 90
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
#region ?? Helper ??????????????????????????????????????????????????????????????
function Get-AllGraphPages {
<#
.SYNOPSIS Handles paged Graph responses, returning all items as a flat array. #>
param(
[Parameter(Mandatory)][string]$Uri,
[hashtable]$QueryParams = @{}
)
$results = [System.Collections.Generic.List[object]]::new()
$nextLink = $Uri
# Append query params to initial URI
if ($QueryParams.Count -gt 0) {
$qs = ($QueryParams.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join "&"
$nextLink = "$Uri`?$qs"
}
do {
$response = Invoke-MgGraphRequest -Method GET -Uri $nextLink
if ($response.value) { $results.AddRange($response.value) }
# Safely retrieve nextLink — property won't exist on the final page, and
# StrictMode -Version Latest throws on missing properties, so we check first.
$nextLink = if ($response.PSObject.Properties['@odata.nextLink']) {
$response.'@odata.nextLink'
} else { $null }
} while ($nextLink)
return $results
}
#endregion
#region ?? Connect ?????????????????????????????????????????????????????????????
Write-Host "`n[+] Connecting to Microsoft Graph..." -ForegroundColor Cyan
$connectParams = @{
Scopes = @(
"Device.Read.All",
"DeviceManagementManagedDevices.Read.All"
)
NoWelcome = $true
}
if ($TenantId) {
$connectParams["TenantId"] = $TenantId
Write-Host " Tenant : $TenantId" -ForegroundColor Gray
}
Connect-MgGraph @connectParams
$context = Get-MgContext
Write-Host " Signed in as : $($context.Account)" -ForegroundColor Gray
Write-Host " Tenant ID : $($context.TenantId)" -ForegroundColor Gray
#endregion
#region ?? Fetch Entra Devices ?????????????????????????????????????????????????
Write-Host "`n[+] Fetching Windows devices from Entra ID..." -ForegroundColor Cyan
# Build OData filter based on join type selection.
# Workplace Joined (trustType 'Workplace') are personal/registered devices - explicitly
# excluded from All to prevent duplicates where a device has both an AADJ and a Registered object.
$osFilter = "operatingSystem eq 'Windows'"
$joinFilters = @{
All = "$osFilter and (trustType eq 'AzureAd' or trustType eq 'ServerAd')"
AADJ = "$osFilter and trustType eq 'AzureAd'"
HAADJ = "$osFilter and trustType eq 'ServerAd'"
}
$entraUri = "https://graph.microsoft.com/v1.0/devices"
$entraQueryParams = @{
'$filter' = $joinFilters[$JoinType]
'$select' = "id,deviceId,displayName,operatingSystem,operatingSystemVersion,trustType,approximateLastSignInDateTime,accountEnabled,isCompliant,isManaged"
'$top' = "999"
}
$entraDevices = Get-AllGraphPages -Uri $entraUri -QueryParams $entraQueryParams
Write-Host " Found $($entraDevices.Count) Windows devices in Entra ID (filter: $JoinType)" -ForegroundColor Gray
# Optional: exclude stale devices
if ($ExcludeStaleDevices) {
$cutoff = (Get-Date).AddDays(-$StaleDaysThreshold)
$before = $entraDevices.Count
$entraDevices = $entraDevices | Where-Object {
$_.approximateLastSignInDateTime -and
([datetime]$_.approximateLastSignInDateTime) -ge $cutoff
}
Write-Host " Excluded $($before - $entraDevices.Count) stale devices (inactive > $StaleDaysThreshold days)" -ForegroundColor Gray
}
# Deduplicate by deviceId — prefer AADJ over HAADJ if both exist for same deviceId.
# This handles edge cases where a device has multiple Entra objects (e.g. re-registered).
$trustPriority = @{ 'AzureAd' = 0; 'ServerAd' = 1 }
$entraDevices = $entraDevices |
Group-Object -Property { $_.deviceId } |
ForEach-Object {
$_.Group |
Sort-Object { $trustPriority[($_.trustType)] ?? 99 } |
Select-Object -First 1
}
Write-Host " After deduplication: $($entraDevices.Count) unique devices" -ForegroundColor Gray
#endregion
#region ?? Fetch Intune Devices ????????????????????????????????????????????????
Write-Host "`n[+] Fetching enrolled devices from Intune..." -ForegroundColor Cyan
$intuneUri = "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices"
$intuneQueryParams = @{
'$filter' = "operatingSystem eq 'Windows'"
'$select' = "id,azureADDeviceId,deviceName,lastSyncDateTime,complianceState,managementState"
'$top' = "999"
}
$intuneDevices = Get-AllGraphPages -Uri $intuneUri -QueryParams $intuneQueryParams
Write-Host " Found $($intuneDevices.Count) Windows devices in Intune" -ForegroundColor Gray
# Build a HashSet of Intune device IDs (azureADDeviceId maps to Entra deviceId)
$intuneDeviceIds = [System.Collections.Generic.HashSet[string]]::new(
[System.StringComparer]::OrdinalIgnoreCase
)
foreach ($d in $intuneDevices) {
if ($d.azureADDeviceId) { [void]$intuneDeviceIds.Add($d.azureADDeviceId) }
}
#endregion
#region ?? Compare & Build Report ?????????????????????????????????????????????
Write-Host "`n[+] Comparing..." -ForegroundColor Cyan
$report = [System.Collections.Generic.List[PSCustomObject]]::new()
foreach ($device in $entraDevices) {
if (-not $intuneDeviceIds.Contains($device.deviceId)) {
# Map trustType to a friendly label
$joinTypeFriendly = switch ($device.trustType) {
"AzureAd" { "Azure AD Joined (AADJ)" }
"ServerAd" { "Hybrid Azure AD Joined (HAADJ)" }
default { if ($device.trustType) { $device.trustType } else { "Unknown" } }
}
$lastSignIn = if ($device.approximateLastSignInDateTime) {
[datetime]$device.approximateLastSignInDateTime
} else { $null }
$daysSinceSignIn = if ($lastSignIn) {
[math]::Round(((Get-Date) - $lastSignIn).TotalDays, 0)
} else { "Unknown" }
$report.Add([PSCustomObject]@{
DisplayName = $device.displayName
DeviceId = $device.deviceId
EntraObjectId = $device.id
OSVersion = $device.operatingSystemVersion
JoinType = $joinTypeFriendly
AccountEnabled = $device.accountEnabled
IsCompliant = $device.isCompliant
IsManaged = $device.isManaged
LastSignIn = if ($lastSignIn) { $lastSignIn.ToString("yyyy-MM-dd") } else { "Never/Unknown" }
DaysSinceLastSignIn = $daysSinceSignIn
})
}
}
Write-Host " Devices in Entra NOT in Intune: $($report.Count)" -ForegroundColor Yellow
#endregion
#region ?? Output ??????????????????????????????????????????????????????????????
if ($report.Count -eq 0) {
Write-Host "`n[?] All Entra Windows devices are enrolled in Intune. Nothing to report." -ForegroundColor Green
}
else {
# Summary breakdown
Write-Host "`n?? Summary ???????????????????????????????????????????????" -ForegroundColor Cyan
$report | Group-Object JoinType | Sort-Object Count -Descending | ForEach-Object {
Write-Host " $($_.Name): $($_.Count) device(s)" -ForegroundColor White
}
$staleCount = ($report | Where-Object { $_.DaysSinceLastSignIn -ne "Unknown" -and [int]$_.DaysSinceLastSignIn -gt 90 }).Count
if ($staleCount -gt 0) {
Write-Host " Potentially stale (>90 days inactive): $staleCount" -ForegroundColor DarkYellow
}
Write-Host "??????????????????????????????????????????????????????????`n" -ForegroundColor Cyan
if ($ExportCsv) {
$report | Export-Csv -Path $ExportCsv -NoTypeInformation -Encoding UTF8
Write-Host "[?] Report exported to: $ExportCsv" -ForegroundColor Green
}
else {
$report | Sort-Object DaysSinceLastSignIn -Descending |
Format-Table -AutoSize DisplayName, JoinType, OSVersion, AccountEnabled, IsCompliant, LastSignIn, DaysSinceLastSignIn
}
}
#endregion
# Disconnect cleanly (optional — comment out if running in a pipeline)
Disconnect-MgGraph | Out-Null
Write-Host "`n[+] Done. Graph session disconnected." -ForegroundColor Cyan