Home About Tools Projects Guides & Blog ⚡ Hire Me ✦ Websites Contact →
Home Projects Graph API Compliance Reporting
PowerShell · Graph API · Intune

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.

Status Complete
Language PowerShell 7
API Microsoft Graph v1.0
Licence Intune / M365 BP
Category Automation · Scripting
// overview

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.

📊
What you get. A daily email with a summary (total devices, compliant count, non-compliant count, percentage) plus a full table showing every device, its OS version, last check-in time, compliance state, and assigned user. Non-compliant devices are highlighted in red so they're impossible to miss.
// setup

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.

1
Create the app registration
In the Entra ID portal, go to App Registrations and create a new registration. Name it something clear like Intune-Compliance-Reporter. Single tenant, no redirect URI needed.
2
Add API permissions
Under API Permissions, add Microsoft Graph application permissions. The script needs three permissions: DeviceManagementManagedDevices.Read.All, User.Read.All, and Mail.Send. Grant admin consent for all three.
3
Create a client secret
Under Certificates and Secrets, add a client secret with a 24-month expiry. Copy the value immediately as you can't retrieve it again. Store it in a secure location, not in the script itself.
4
Note your tenant ID and client ID
You'll find both on the app registration Overview page. These three values (tenant ID, client ID, client secret) are the only credentials the script needs.
PermissionTypeWhy it's needed
DeviceManagementManagedDevices.Read.AllREADQuery all Intune-managed devices and their compliance state
User.Read.AllREADResolve device owner display names from user IDs
Mail.SendSENDSend the report email from a shared mailbox
⚠️
Mail.Send is an application permission, not delegated. The email will be sent from the mailbox you specify, so use a shared mailbox or a dedicated reporting account. Anyone who has a rule on their mailbox blocking automated mail could miss the report.
// the script

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.

// PART 1 - Authenticate to Graph API
# 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)" }
// PART 2 - Pull device compliance data
# 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
// PART 3 - Build HTML report (excerpt)
$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>"
}
💡
Pagination matters. The Graph API returns a maximum of 999 results per page. The @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.
// deployment

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.

// Set environment variables on the host (run once, as admin)
# 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:

// Task Scheduler action
# Program: powershell.exe
# Arguments:
-ExecutionPolicy Bypass -NonInteractive -File "C:\Scripts\ComplianceReport.ps1"
// outcomes

What this replaced

0
Minutes of manual effort per day
~30s
Script execution time for 150 devices
£0
Additional cost over existing M365 licence
Daily
Compliance visibility for all stakeholders
🔁
Easy to extend. The same authentication pattern works for any Graph API endpoint. I've since added a second script that pulls Azure AD sign-in risk events weekly using the same app registration. Once you've got the auth pattern working, pulling different data is just changing the URI.