Why Build This?
When a customer asks "how many of our devices are compliant right now?" most engineers open the Intune portal and click through the compliance reports. That's fine for a one-off. But if you need to share this regularly, automate escalations, or build something a manager can refresh themselves — you need Graph.
This walkthrough builds a self-contained PowerShell script that queries Microsoft Graph and generates a clean HTML dashboard. No Power BI. No Azure subscription. Just Graph + PowerShell.
Prerequisites
- Microsoft.Graph PowerShell module (or we'll use direct REST calls)
- An app registration in Entra ID with
DeviceManagementManagedDevices.Read.Allpermission - PowerShell 7+
Step 1 — App Registration
In the Entra admin center:
- Go to App registrations → New registration
- Name it
Intune-Reporting - Under API permissions, add
DeviceManagementManagedDevices.Read.All(Application permission) - Grant admin consent
- Create a Client secret and note it down
Step 2 — Authenticate and Get a Token
$tenantId = "your-tenant-id"
$clientId = "your-app-client-id"
$clientSecret = "your-client-secret"
$body = @{
grant_type = "client_credentials"
scope = "https://graph.microsoft.com/.default"
client_id = $clientId
client_secret = $clientSecret
}
$tokenResponse = Invoke-RestMethod `
-Uri "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/token" `
-Method POST -Body $body
$token = $tokenResponse.access_token
Step 3 — Query Managed Devices
$headers = @{ Authorization = "Bearer $token" }
$uri = "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices" +
"?`$select=deviceName,complianceState,osVersion,userPrincipalName,lastSyncDateTime,operatingSystem" +
"&`$top=999"
$devices = @()
do {
$response = Invoke-RestMethod -Uri $uri -Headers $headers -Method GET
$devices += $response.value
$uri = $response.'@odata.nextLink'
} while ($uri)
Write-Host "Total devices: $($devices.Count)"
The do/while loop handles pagination — critical for tenants with 500+ devices.
Step 4 — Generate the HTML Report
$compliant = ($devices | Where-Object complianceState -eq "compliant").Count
$nonCompliant = ($devices | Where-Object complianceState -eq "noncompliant").Count
$unknown = ($devices | Where-Object complianceState -eq "unknown").Count
$total = $devices.Count
$compliancePct = [math]::Round(($compliant / $total) * 100, 1)
$rows = $devices | Sort-Object complianceState | ForEach-Object {
$stateColor = switch ($_.complianceState) {
"compliant" { "#15803d" }
"noncompliant" { "#dc2626" }
default { "#9ca3af" }
}
"<tr>
<td>$($_.deviceName)</td>
<td>$($_.userPrincipalName)</td>
<td>$($_.operatingSystem) $($_.osVersion)</td>
<td style='color:$stateColor;font-weight:600'>$($_.complianceState)</td>
<td>$($_.lastSyncDateTime)</td>
</tr>"
}
$html = @"
<!DOCTYPE html>
<html>
<head><title>Intune Compliance Report</title></head>
<body style="font-family:sans-serif;max-width:1100px;margin:40px auto;padding:0 24px">
<h1>Intune Compliance — $(Get-Date -Format 'MMMM dd, yyyy')</h1>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin:24px 0">
<div style="padding:20px;background:#f9fafb;border-radius:8px;border:1px solid #e5e7eb">
<div style="font-size:28px;font-weight:700">$total</div><div>Total Devices</div>
</div>
<div style="padding:20px;background:#f0fdf4;border-radius:8px;border:1px solid #bbf7d0">
<div style="font-size:28px;font-weight:700;color:#15803d">$compliant</div><div>Compliant</div>
</div>
<div style="padding:20px;background:#fef2f2;border-radius:8px;border:1px solid #fecaca">
<div style="font-size:28px;font-weight:700;color:#dc2626">$nonCompliant</div><div>Non-Compliant</div>
</div>
<div style="padding:20px;background:#eff6ff;border-radius:8px;border:1px solid #bfdbfe">
<div style="font-size:28px;font-weight:700;color:#1d4ed8">$compliancePct%</div><div>Compliance Rate</div>
</div>
</div>
<table style="width:100%;border-collapse:collapse;font-size:14px">
<thead><tr style="background:#f3f4f6">
<th style="padding:10px;text-align:left;border:1px solid #e5e7eb">Device</th>
<th style="padding:10px;text-align:left;border:1px solid #e5e7eb">User</th>
<th style="padding:10px;text-align:left;border:1px solid #e5e7eb">OS</th>
<th style="padding:10px;text-align:left;border:1px solid #e5e7eb">State</th>
<th style="padding:10px;text-align:left;border:1px solid #e5e7eb">Last Sync</th>
</tr></thead>
<tbody>$($rows -join '')</tbody>
</table>
</body></html>
"@
$html | Out-File "compliance-report.html" -Encoding UTF8
Start-Process "compliance-report.html"
Automating Weekly Reports
Schedule this in Azure Automation or a simple Task Scheduler job on a management server. Store the client secret in Azure Key Vault and retrieve it at runtime — never hardcode secrets.
# Retrieve secret from Key Vault
$secret = Get-AzKeyVaultSecret -VaultName "YourVault" -Name "IntuneReportingSecret" -AsPlainText
What to Build Next
Once you have this baseline, you can extend it to:
- Filter for devices not synced in 30+ days
- Export non-compliant devices to a Teams channel via webhook
- Compare compliance across device platforms (Windows vs iOS vs Android)
- Trigger automated remediation scripts via Graph for specific policy failures