{"id":9697,"date":"2026-06-03T08:04:13","date_gmt":"2026-06-03T08:04:13","guid":{"rendered":"https:\/\/pariswells.com\/blog\/?p=9697"},"modified":"2026-06-03T08:04:14","modified_gmt":"2026-06-03T08:04:14","slug":"finds-windows-devices-joined-to-entra-id-azure-ad-that-are-not-enrolled-in-intune-powershell-via-graph","status":"publish","type":"post","link":"https:\/\/pariswells.com\/blog\/research\/finds-windows-devices-joined-to-entra-id-azure-ad-that-are-not-enrolled-in-intune-powershell-via-graph","title":{"rendered":"Finds Windows devices joined to Entra ID (Azure AD) that are NOT enrolled in Intune Powershell via Graph"},"content":{"rendered":"\n<pre class=\"wp-block-code\"><code class=\"\">#Requires -Modules Microsoft.Graph.Authentication, Microsoft.Graph.Identity.DirectoryManagement, Microsoft.Graph.DeviceManagement\n\n&lt;#\n.SYNOPSIS\n    Finds Windows devices joined to Entra ID (Azure AD) that are NOT enrolled in Intune.\n\n.DESCRIPTION\n    Queries Microsoft Graph for all Windows devices in Entra ID (Azure AD Joined or Hybrid\n    Azure AD Joined), then compares against Intune-enrolled devices. Outputs a report of\n    devices present in Entra but missing from Intune.\n\n    Supports:\n      - Single tenant or multi-tenant (GDAP\/CSP) via -TenantId\n      - Export to CSV\n      - Filtering by join type (AADJ, HAADJ, or both)\n      - Optional stale device filtering by last sign-in age\n\n.PARAMETER TenantId\n    The target tenant ID or domain. Defaults to your home tenant if omitted.\n\n.PARAMETER ExportCsv\n    Path to export results as a CSV file. If omitted, results are written to the console.\n\n.PARAMETER JoinType\n    Filter by join type. Options: All, AADJ (Azure AD Joined only), HAADJ (Hybrid only).\n    Default: All\n\n.PARAMETER ExcludeStaleDevices\n    If specified, devices with no sign-in activity in the last N days are excluded.\n\n.PARAMETER StaleDaysThreshold\n    Number of days of inactivity to consider a device stale. Default: 90.\n    Only used when -ExcludeStaleDevices is set.\n\n.EXAMPLE\n    # Run against your home tenant, output to console\n    .\\Get-EntraNotInIntune.ps1\n\n.EXAMPLE\n    # Run against a specific tenant (GDAP), export to CSV\n    .\\Get-EntraNotInIntune.ps1 -TenantId \"contoso.onmicrosoft.com\" -ExportCsv \"C:\\Reports\\EntraNotInIntune.csv\"\n\n.EXAMPLE\n    # AADJ only, exclude devices inactive for 60+ days\n    .\\Get-EntraNotInIntune.ps1 -JoinType AADJ -ExcludeStaleDevices -StaleDaysThreshold 60\n\n.NOTES\n    Required Graph API Permissions (Application or Delegated):\n      - Device.Read.All\n      - DeviceManagementManagedDevices.Read.All\n\n    Install modules if needed:\n      Install-Module Microsoft.Graph -Scope CurrentUser\n#>\n\n[CmdletBinding()]\nparam(\n    [Parameter()]\n    [string]$TenantId,\n\n    [Parameter()]\n    [string]$ExportCsv,\n\n    [Parameter()]\n    [ValidateSet(\"All\", \"AADJ\", \"HAADJ\")]\n    [string]$JoinType = \"All\",\n\n    [Parameter()]\n    [switch]$ExcludeStaleDevices,\n\n    [Parameter()]\n    [int]$StaleDaysThreshold = 90\n)\n\nSet-StrictMode -Version Latest\n$ErrorActionPreference = \"Stop\"\n\n#region ?? Helper ??????????????????????????????????????????????????????????????\n\nfunction Get-AllGraphPages {\n    &lt;#\n    .SYNOPSIS Handles paged Graph responses, returning all items as a flat array. #>\n    param(\n        [Parameter(Mandatory)][string]$Uri,\n        [hashtable]$QueryParams = @{}\n    )\n\n    $results = [System.Collections.Generic.List[object]]::new()\n    $nextLink = $Uri\n\n    # Append query params to initial URI\n    if ($QueryParams.Count -gt 0) {\n        $qs = ($QueryParams.GetEnumerator() | ForEach-Object { \"$($_.Key)=$($_.Value)\" }) -join \"&amp;\"\n        $nextLink = \"$Uri`?$qs\"\n    }\n\n    do {\n        $response = Invoke-MgGraphRequest -Method GET -Uri $nextLink\n        if ($response.value) { $results.AddRange($response.value) }\n        # Safely retrieve nextLink \u2014 property won't exist on the final page, and\n        # StrictMode -Version Latest throws on missing properties, so we check first.\n        $nextLink = if ($response.PSObject.Properties['@odata.nextLink']) {\n            $response.'@odata.nextLink'\n        } else { $null }\n    } while ($nextLink)\n\n    return $results\n}\n\n#endregion\n\n#region ?? Connect ?????????????????????????????????????????????????????????????\n\nWrite-Host \"`n[+] Connecting to Microsoft Graph...\" -ForegroundColor Cyan\n\n$connectParams = @{\n    Scopes = @(\n        \"Device.Read.All\",\n        \"DeviceManagementManagedDevices.Read.All\"\n    )\n    NoWelcome = $true\n}\n\nif ($TenantId) {\n    $connectParams[\"TenantId\"] = $TenantId\n    Write-Host \"    Tenant : $TenantId\" -ForegroundColor Gray\n}\n\nConnect-MgGraph @connectParams\n\n$context = Get-MgContext\nWrite-Host \"    Signed in as : $($context.Account)\" -ForegroundColor Gray\nWrite-Host \"    Tenant ID    : $($context.TenantId)\" -ForegroundColor Gray\n\n#endregion\n\n#region ?? Fetch Entra Devices ?????????????????????????????????????????????????\n\nWrite-Host \"`n[+] Fetching Windows devices from Entra ID...\" -ForegroundColor Cyan\n\n# Build OData filter based on join type selection.\n# Workplace Joined (trustType 'Workplace') are personal\/registered devices - explicitly\n# excluded from All to prevent duplicates where a device has both an AADJ and a Registered object.\n$osFilter    = \"operatingSystem eq 'Windows'\"\n$joinFilters = @{\n    All   = \"$osFilter and (trustType eq 'AzureAd' or trustType eq 'ServerAd')\"\n    AADJ  = \"$osFilter and trustType eq 'AzureAd'\"\n    HAADJ = \"$osFilter and trustType eq 'ServerAd'\"\n}\n\n$entraUri = \"https:\/\/graph.microsoft.com\/v1.0\/devices\"\n$entraQueryParams = @{\n    '$filter' = $joinFilters[$JoinType]\n    '$select' = \"id,deviceId,displayName,operatingSystem,operatingSystemVersion,trustType,approximateLastSignInDateTime,accountEnabled,isCompliant,isManaged\"\n    '$top'    = \"999\"\n}\n\n$entraDevices = Get-AllGraphPages -Uri $entraUri -QueryParams $entraQueryParams\nWrite-Host \"    Found $($entraDevices.Count) Windows devices in Entra ID (filter: $JoinType)\" -ForegroundColor Gray\n\n# Optional: exclude stale devices\nif ($ExcludeStaleDevices) {\n    $cutoff = (Get-Date).AddDays(-$StaleDaysThreshold)\n    $before = $entraDevices.Count\n    $entraDevices = $entraDevices | Where-Object {\n        $_.approximateLastSignInDateTime -and\n        ([datetime]$_.approximateLastSignInDateTime) -ge $cutoff\n    }\n    Write-Host \"    Excluded $($before - $entraDevices.Count) stale devices (inactive > $StaleDaysThreshold days)\" -ForegroundColor Gray\n}\n\n# Deduplicate by deviceId \u2014 prefer AADJ over HAADJ if both exist for same deviceId.\n# This handles edge cases where a device has multiple Entra objects (e.g. re-registered).\n$trustPriority = @{ 'AzureAd' = 0; 'ServerAd' = 1 }\n$entraDevices = $entraDevices |\n    Group-Object -Property { $_.deviceId } |\n    ForEach-Object {\n        $_.Group |\n            Sort-Object { $trustPriority[($_.trustType)] ?? 99 } |\n            Select-Object -First 1\n    }\nWrite-Host \"    After deduplication: $($entraDevices.Count) unique devices\" -ForegroundColor Gray\n\n#endregion\n\n#region ?? Fetch Intune Devices ????????????????????????????????????????????????\n\nWrite-Host \"`n[+] Fetching enrolled devices from Intune...\" -ForegroundColor Cyan\n\n$intuneUri = \"https:\/\/graph.microsoft.com\/v1.0\/deviceManagement\/managedDevices\"\n$intuneQueryParams = @{\n    '$filter' = \"operatingSystem eq 'Windows'\"\n    '$select' = \"id,azureADDeviceId,deviceName,lastSyncDateTime,complianceState,managementState\"\n    '$top'    = \"999\"\n}\n\n$intuneDevices = Get-AllGraphPages -Uri $intuneUri -QueryParams $intuneQueryParams\nWrite-Host \"    Found $($intuneDevices.Count) Windows devices in Intune\" -ForegroundColor Gray\n\n# Build a HashSet of Intune device IDs (azureADDeviceId maps to Entra deviceId)\n$intuneDeviceIds = [System.Collections.Generic.HashSet[string]]::new(\n    [System.StringComparer]::OrdinalIgnoreCase\n)\nforeach ($d in $intuneDevices) {\n    if ($d.azureADDeviceId) { [void]$intuneDeviceIds.Add($d.azureADDeviceId) }\n}\n\n#endregion\n\n#region ?? Compare &amp; Build Report ?????????????????????????????????????????????\n\nWrite-Host \"`n[+] Comparing...\" -ForegroundColor Cyan\n\n$report = [System.Collections.Generic.List[PSCustomObject]]::new()\n\nforeach ($device in $entraDevices) {\n    if (-not $intuneDeviceIds.Contains($device.deviceId)) {\n\n        # Map trustType to a friendly label\n        $joinTypeFriendly = switch ($device.trustType) {\n            \"AzureAd\"  { \"Azure AD Joined (AADJ)\" }\n            \"ServerAd\" { \"Hybrid Azure AD Joined (HAADJ)\" }\n            default    { if ($device.trustType) { $device.trustType } else { \"Unknown\" } }\n        }\n\n        $lastSignIn = if ($device.approximateLastSignInDateTime) {\n            [datetime]$device.approximateLastSignInDateTime\n        } else { $null }\n\n        $daysSinceSignIn = if ($lastSignIn) {\n            [math]::Round(((Get-Date) - $lastSignIn).TotalDays, 0)\n        } else { \"Unknown\" }\n\n        $report.Add([PSCustomObject]@{\n            DisplayName          = $device.displayName\n            DeviceId             = $device.deviceId\n            EntraObjectId        = $device.id\n            OSVersion            = $device.operatingSystemVersion\n            JoinType             = $joinTypeFriendly\n            AccountEnabled       = $device.accountEnabled\n            IsCompliant          = $device.isCompliant\n            IsManaged            = $device.isManaged\n            LastSignIn           = if ($lastSignIn) { $lastSignIn.ToString(\"yyyy-MM-dd\") } else { \"Never\/Unknown\" }\n            DaysSinceLastSignIn  = $daysSinceSignIn\n        })\n    }\n}\n\nWrite-Host \"    Devices in Entra NOT in Intune: $($report.Count)\" -ForegroundColor Yellow\n\n#endregion\n\n#region ?? Output ??????????????????????????????????????????????????????????????\n\nif ($report.Count -eq 0) {\n    Write-Host \"`n[?] All Entra Windows devices are enrolled in Intune. Nothing to report.\" -ForegroundColor Green\n}\nelse {\n    # Summary breakdown\n    Write-Host \"`n?? Summary ???????????????????????????????????????????????\" -ForegroundColor Cyan\n    $report | Group-Object JoinType | Sort-Object Count -Descending | ForEach-Object {\n        Write-Host \"   $($_.Name): $($_.Count) device(s)\" -ForegroundColor White\n    }\n\n    $staleCount = ($report | Where-Object { $_.DaysSinceLastSignIn -ne \"Unknown\" -and [int]$_.DaysSinceLastSignIn -gt 90 }).Count\n    if ($staleCount -gt 0) {\n        Write-Host \"   Potentially stale (>90 days inactive): $staleCount\" -ForegroundColor DarkYellow\n    }\n    Write-Host \"??????????????????????????????????????????????????????????`n\" -ForegroundColor Cyan\n\n    if ($ExportCsv) {\n        $report | Export-Csv -Path $ExportCsv -NoTypeInformation -Encoding UTF8\n        Write-Host \"[?] Report exported to: $ExportCsv\" -ForegroundColor Green\n    }\n    else {\n        $report | Sort-Object DaysSinceLastSignIn -Descending |\n            Format-Table -AutoSize DisplayName, JoinType, OSVersion, AccountEnabled, IsCompliant, LastSignIn, DaysSinceLastSignIn\n    }\n}\n\n#endregion\n\n# Disconnect cleanly (optional \u2014 comment out if running in a pipeline)\nDisconnect-MgGraph | Out-Null\nWrite-Host \"`n[+] Done. Graph session disconnected.\" -ForegroundColor Cyan<\/code><\/pre>\n","protected":false},"excerpt":{"rendered":"","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[1],"tags":[],"class_list":["post-9697","post","type-post","status-publish","format-standard","hentry","category-research"],"aioseo_notices":[],"_links":{"self":[{"href":"https:\/\/pariswells.com\/blog\/wp-json\/wp\/v2\/posts\/9697","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/pariswells.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/pariswells.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/pariswells.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/pariswells.com\/blog\/wp-json\/wp\/v2\/comments?post=9697"}],"version-history":[{"count":1,"href":"https:\/\/pariswells.com\/blog\/wp-json\/wp\/v2\/posts\/9697\/revisions"}],"predecessor-version":[{"id":9698,"href":"https:\/\/pariswells.com\/blog\/wp-json\/wp\/v2\/posts\/9697\/revisions\/9698"}],"wp:attachment":[{"href":"https:\/\/pariswells.com\/blog\/wp-json\/wp\/v2\/media?parent=9697"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/pariswells.com\/blog\/wp-json\/wp\/v2\/categories?post=9697"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/pariswells.com\/blog\/wp-json\/wp\/v2\/tags?post=9697"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}