Automated Compliance
Reporting via Graph API
How I built a fully automated Intune device compliance report using PowerShell and the Microsoft Graph API. Runs on a schedule, pulls live data, formats an HTML report, and emails it to stakeholders without any manual steps.
The problem this solves
Manually checking device compliance in the Intune portal works fine when you have 20 devices. When you've got 150 across multiple sites, opening the portal every morning to check for non-compliant devices isn't practical. You miss things, and non-compliance tends to linger because there's no systematic nudge to address it.
This project automates the whole process. A scheduled task runs a PowerShell script each morning, authenticates to Graph API using an app registration, queries Intune for all managed devices and their compliance state, builds an HTML report, and emails it to whoever needs to see it. The whole thing takes about 30 seconds to run and costs nothing to host.
App registration and permissions
The script authenticates as an app registration in Entra ID using the client credentials flow. This means no interactive login, no token refresh issues, and no dependency on a specific user account. The app gets the minimum permissions it needs and nothing else.
| Permission | Type | Why it's needed |
|---|---|---|
DeviceManagementManagedDevices.Read.All | READ | Query all Intune-managed devices and their compliance state |
User.Read.All | READ | Resolve device owner display names from user IDs |
Mail.Send | SEND | Send the report email from a shared mailbox |
How the script works
The script is structured in four parts: authenticate, pull data, build the HTML, send the email. Each part is in its own function so it's easy to modify or extend.
# Credentials stored as environment variables, not in the script $tenantId = $env:GRAPH_TENANT_ID $clientId = $env:GRAPH_CLIENT_ID $clientSecret = $env:GRAPH_CLIENT_SECRET $body = @{ grant_type = "client_credentials" client_id = $clientId client_secret = $clientSecret scope = "https://graph.microsoft.com/.default" } $tokenResponse = Invoke-RestMethod -Method Post ` -Uri "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/token" ` -Body $body $headers = @{ Authorization = "Bearer $($tokenResponse.access_token)" }
# Fetch all managed devices with compliance state $devices = @() $uri = "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices? `$select=deviceName,complianceState,osVersion,lastSyncDateTime,userDisplayName,operatingSystem" do { $response = Invoke-RestMethod -Uri $uri -Headers $headers $devices += $response.value $uri = $response.'@odata.nextLink' # handles pagination } while ($uri) $compliant = ($devices | Where-Object { $_.complianceState -eq 'compliant' }).Count $nonCompliant = ($devices | Where-Object { $_.complianceState -ne 'compliant' }).Count
$rows = foreach ($d in $devices | Sort-Object complianceState) { $colour = if ($d.complianceState -ne 'compliant') { '#fef2f2' } else { '#fff' } $lastSync = [datetime]$d.lastSyncDateTime "<tr style='background:$colour'> <td>$($d.deviceName)</td> <td>$($d.userDisplayName)</td> <td>$($d.operatingSystem) $($d.osVersion)</td> <td>$($lastSync.ToString('dd/MM/yyyy HH:mm'))</td> <td><strong>$($d.complianceState)</strong></td> </tr>" }
@odata.nextLink property in the response tells you if there are more pages. The do-while loop above handles this automatically, so the report works for tenants of any size.Scheduling and secret management
The script runs via Windows Task Scheduler on a management VM, but it could just as easily run in Azure Automation or as an Azure Function. The credentials are stored as system environment variables on the host, not hardcoded in the script. This means the script file itself contains no sensitive data and can be stored in source control.
# Run these once on the machine that will execute the script [System.Environment]::SetEnvironmentVariable( "GRAPH_TENANT_ID", "your-tenant-id", "Machine") [System.Environment]::SetEnvironmentVariable( "GRAPH_CLIENT_ID", "your-client-id", "Machine") [System.Environment]::SetEnvironmentVariable( "GRAPH_CLIENT_SECRET", "your-secret", "Machine")
The Task Scheduler trigger runs at 07:30 Monday to Friday, before most staff arrive. The action is a simple PowerShell call:
# Program: powershell.exe # Arguments: -ExecutionPolicy Bypass -NonInteractive -File "C:\Scripts\ComplianceReport.ps1"