Using Graph to Manage Intune Devices

I manage Intune for a living. Clicking through the portal works fine when you're dealing with a handful of devices, but once you're past a few hundred, you start writing scripts. And once you start writing scripts against Microsoft 365, you end up at Microsoft Graph whether you planned to or not.

Microsoft's end goal is to make Graph API the

ONE API TO RULE THEM ALL

On the Azure platform, at least.

I'd love to see that happen. But this is the platform where Exchange Online has had, and always will have, its own little safe space away from standardization. So we'll see.

Anyway. This post covers the basics of managing Intune devices through the Microsoft Graph PowerShell SDK. Not raw REST calls (though I'll mention the endpoints so you know what's happening under the hood). Just the cmdlets, what scopes you need, and a few things I wish someone had told me before I started.

Getting set up

You need the Microsoft Graph PowerShell SDK. If you've been using the old AzureAD module, stop. Microsoft killed it in 2025.

Install-Module Microsoft.Graph.DeviceManagement -Scope CurrentUser

Don't install the entire SDK. It's almost a gigabyte. Just grab the submodules you actually need; Microsoft.Graph.DeviceManagement covers most Intune work.

Connect with the right scopes:

# Read-only access to managed devices
Connect-MgGraph -Scopes "DeviceManagementManagedDevices.Read.All"

# If you need to trigger remote actions (wipe, sync, retire):
Connect-MgGraph -Scopes "DeviceManagementManagedDevices.ReadWrite.All"

Don't just slap ReadWrite.All on everything because it's easier. I've seen service accounts with God-mode permissions that nobody remembered creating until something went wrong. And ReadWrite.All on device management isn't just "can update stuff." It means the account can factory-reset every device in your tenant. Keep that in mind.

Quick note: all the examples below use interactive login, which is fine for testing. For scheduled tasks or anything unattended, you'll want a service principal with certificate auth instead. Register an app in Entra ID, grant the API permissions, upload a certificate, and connect like this:

Connect-MgGraph -ClientId "your-app-id" `
    -TenantId "your-tenant-id" `
    -CertificateThumbprint "your-cert-thumbprint"

Don't store client secrets in scripts. Use certificates, or if you're running in Azure, use a managed identity. I'm not going to walk through the full app registration flow here (that's its own post), but Microsoft's docs cover it well.

Querying devices

Most of your time will be spent pulling device lists. Compliance reports, finding Windows 10 machines that haven't checked in for 90 days, or some spreadsheet your manager needs. It's always a spreadsheet.

# Get all managed devices
Get-MgDeviceManagementManagedDevice -All

# That's the equivalent of hitting:
# GET https://graph.microsoft.com/v1.0/deviceManagement/managedDevices

On any real tenant, don't pull everything and filter locally. Use server-side OData filters:

# Just Windows devices
Get-MgDeviceManagementManagedDevice -Filter "operatingSystem eq 'Windows'" -All

# Non-compliant devices only
Get-MgDeviceManagementManagedDevice -Filter "complianceState eq 'noncompliant'" -All

# Devices that haven't synced in 90 days
$cutoff = (Get-Date).AddDays(-90).ToString("yyyy-MM-ddTHH:mm:ssZ")
Get-MgDeviceManagementManagedDevice -Filter "lastSyncDateTime le $cutoff" -All

That last one is the one I run the most. Stale devices pile up fast, and nobody notices until the license count looks wrong or compliance numbers tank.

Pulling every property on every device is slow. Specify what you need:

Get-MgDeviceManagementManagedDevice -All -Property deviceName,operatingSystem,complianceState,lastSyncDateTime |
    Select-Object deviceName, operatingSystem, complianceState, lastSyncDateTime |
    Export-Csv -Path "devices.csv" -NoTypeInformation

(The -Property flag maps to $select in the Graph API. It cuts the payload size, which matters when you're pulling thousands of records.)

If you want to test your filters before writing scripts, Graph Explorer lets you run queries against your tenant in the browser. Good for figuring out which properties are actually filterable, because not all of them are, and Graph doesn't always give you a helpful error when one isn't. Stick to v1.0 endpoints for anything going into production; beta endpoints change without warning.

Remote actions

Syncing one device in the portal is fine. Syncing 200 because a policy change didn't propagate is not. That's a script.

Under the hood, these are POST requests to action endpoints. The SDK has dedicated cmdlets for some of them, but half the time I end up using Invoke-MgGraphRequest directly because it's simpler and I don't have to figure out which submodule has the cmdlet I need. One thing to watch: Invoke-MgGraphRequest doesn't handle pagination for you the way the SDK cmdlets do. For action endpoints that's not an issue (they don't return paged results), but keep it in mind if you use it for GET requests.

# Force a sync on a specific device
$deviceId = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
Invoke-MgGraphRequest -Method POST `
    -Uri "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices/$deviceId/syncDevice"

Bulk sync, the version that actually saves you time:

# Sync all non-compliant Windows devices
$devices = Get-MgDeviceManagementManagedDevice `
    -Filter "complianceState eq 'noncompliant' and operatingSystem eq 'Windows'" -All

$failed = [System.Collections.Generic.List[PSCustomObject]]::new()
foreach ($device in $devices) {
    try {
        Invoke-MgGraphRequest -Method POST `
            -Uri "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices/$($device.Id)/syncDevice"
        Write-Host "Synced: $($device.DeviceName)"
    }
    catch {
        Write-Warning "Failed to sync $($device.DeviceName): $_"
        $failed.Add([PSCustomObject]@{
            DeviceName = $device.DeviceName
            DeviceId   = $device.Id
            Error      = $_.Exception.Message
        })
    }
    # Throttle to avoid 429s. Graph's actual rate limits depend on your tenant
    # and request type, so adjust if you're still getting throttled.
    Start-Sleep -Milliseconds 1200
}

if ($failed.Count -gt 0) {
    $failed | Export-Csv -Path "sync-failures.csv" -NoTypeInformation
    Write-Warning "$($failed.Count) devices failed to sync. See sync-failures.csv"
}

Same pattern works for other actions. Just swap the endpoint:

  • /syncDevice - force a check-in
  • /rebootNow - restart
  • /retire - strips corporate data, leaves personal stuff intact
  • /wipe - full factory reset. The device goes back to the out-of-box experience, and unless it's registered in Autopilot it won't re-enroll itself. Test on a single device first.

One thing to know: these remote actions are async. A 200 or 204 response means Graph accepted the request, not that the device actually did it. There's no built-in way to poll for completion, so if you need confirmation, you'd check the device's lastSyncDateTime afterward.

I shouldn't need to say this, but I've heard stories about bulk wipe scripts with a bad filter. Not mine, thankfully. But I've heard them. For anything destructive, run your filter first with a Select-Object deviceName and eyeball the list before you pipe it into an action loop.

Working with device configurations

Configuration profiles are queryable too. Handy when you're trying to figure out why a specific policy isn't applying, or when someone asks you to document what's deployed.

# List all device configuration profiles
Get-MgDeviceManagementDeviceConfiguration -All |
    Select-Object displayName, id, lastModifiedDateTime

# Get details on a specific profile
Get-MgDeviceManagementDeviceConfiguration -DeviceConfigurationId "your-profile-id-here"

Creating and updating profiles through Graph is possible but honestly a pain. The request bodies are JSON blobs that vary by platform, profile type, and sometimes even the OS version you're targeting. I tried writing them from scratch for a while and gave up. Now I build the profile in the portal first, pull it with Get-MgDeviceManagementDeviceConfiguration, and use that JSON as a template for scripting similar profiles across other tenants. Much faster. Just remember to pipe through ConvertTo-Json -Depth 10 when you export. Without it, PowerShell truncates nested objects after a few levels and you get ... in your JSON. 10 works for most profiles, but if you're dealing with deeply nested settings catalogs, check the output for truncation artifacts before you import it somewhere else.

Enrollment (or: what Graph can't do)

People assume you can enroll devices through Graph. You can't. Enrollment happens on the device side: Company Portal, Windows Autopilot, Apple DEP, Android Enterprise. Graph doesn't kick that off; the device does. I've seen people waste hours looking for an enrollment cmdlet that doesn't exist.

What you can do is manage Autopilot device identities. Hardware refresh coming up and you need to import serial numbers and hardware hashes in bulk:

# Import an Autopilot device
# The hardware hash is a base64-encoded blob (~4KB) that uniquely identifies the hardware.
# Get it from Get-WindowsAutoPilotInfo on each device, or your hardware vendor.
$params = @{
    "@odata.type" = "#microsoft.graph.importedWindowsAutopilotDeviceIdentity"
    serialNumber = "your-serial-here"
    hardwareIdentifier = "base64-encoded-hardware-hash"
}

New-MgDeviceManagementImportedWindowsAutopilotDeviceIdentity -BodyParameter $params

In practice you'd read from a CSV and loop through. The import is slow. Each device takes a few seconds to process on Microsoft's end, and there's no bulk endpoint that's actually faster. For 500 devices, expect at least half an hour.

What this post skipped

I kept this to the basics. Stuff that matters once you're past the beginner phase:

  • Compliance policy management and assignment (querying is easy, creating them via Graph is the same JSON pain as config profiles)
  • Delta queries for tracking changes without re-pulling your entire device list every time. If you're running nightly exports, this is the thing that'll cut your execution time from 20 minutes to 30 seconds. I should probably write that one up.
  • Handling throttling properly (the Start-Sleep in the bulk sync example above is the dumb version; the smart version reads the Retry-After header Graph sends back with 429 responses)
  • Conditional Access policies and how they interact with service principal auth

Popular posts from this blog

Intune Log on Rights