Skip to content
View in the app

A better way to browse. Learn more.

Learn more

Unattended Graph reports from PowerShell with an app registration

Featured Replies

Most of our tenant reporting runs overnight: device inventories, licence counts, stale-account checks. Nobody is sitting there to answer an MFA prompt at 2 a.m., and a job tied to one tech's sign-in breaks the week that person gets a new phone or moves on. So we run these jobs as app registrations using the OAuth2 client credentials flow. The script signs in as the app itself, gets a token, and calls Graph with plain Invoke-RestMethod. No modules are needed, which I appreciate on locked-down jump boxes where installing anything is its own ticket.

This is the small script we start from. It lists users and Intune managed devices, follows paging properly, and keeps the secret out of the file.

Before you start

  • An account that can create app registrations and grant admin consent (Application Administrator, plus a Privileged Role Administrator or Global Administrator to consent to Graph application permissions).
  • PowerShell 5.1 or 7.x.
  • The Microsoft.PowerShell.SecretManagement and Microsoft.PowerShell.SecretStore modules if you go with a client secret.

Registering the app

  1. In the Entra admin center, go to Applications > App registrations > New registration.
  2. Give it a name the next tech will understand (we use something like svc-graph-reporting). Single tenant, no redirect URI.
  3. Copy the Application (client) ID and Directory (tenant) ID from the Overview page.

Permissions, and the consent button everyone forgets

Under API permissions > Add a permission > Microsoft Graph, pick Application permissions, not Delegated. Delegated permissions are for a signed-in user and do nothing in the client credentials flow. This script needs two:

  • User.Read.All
  • DeviceManagementManagedDevices.Read.All

Then click Grant admin consent for <tenant>. This is the step I always forget on a new tenant. Until the status column shows a green tick, you'll still get a token, but every call comes back 403. Ask only for the read permissions you actually need. An app secret with Directory.ReadWrite.All is a tenant-wide key sitting on some server, and I don't want to be the one explaining that to a client.

Where the credential lives

Under Certificates & secrets you can upload a certificate or create a client secret. I prefer certificates, because the private key stays in the machine's certificate store and never gets pasted into a file. If you do use a secret, give it a short expiry, put the renewal date on a calendar someone actually reads, and never hard-code it. We keep ours in a SecretStore vault on the box that runs the job:

Install-Module Microsoft.PowerShell.SecretManagement, Microsoft.PowerShell.SecretStore -Scope CurrentUser
Register-SecretVault -Name LocalStore -ModuleName Microsoft.PowerShell.SecretStore -DefaultVault
Set-Secret -Name 'GraphReportApp' -Secret (Read-Host -AsSecureString 'Client secret')

Run that as the same account the scheduled task runs as, because the vault is per-user. For unattended use you'll also need to set the SecretStore's password behaviour to suit your environment (see Set-SecretStoreConfiguration).

Getting a token and following the pages

The token endpoint is https://login.microsoftonline.com/<TenantID>/oauth2/v2.0/token. With client credentials the scope is always https://graph.microsoft.com/.default, which means "whatever application permissions have been consented". The response contains access_token, which is good for roughly an hour.

Graph hands results back in pages. When there are more, the response includes an @odata.nextLink property with the full URL of the next page, and you keep following it until it's gone. If you skip this, the report quietly stops at the first page (100 users by default). Nobody notices for months, until someone asks why the licence count looks low.

The full script

# Get-GraphInventory.ps1 - list users and Intune managed devices via Microsoft Graph
# Requires: Microsoft.PowerShell.SecretManagement + a registered vault holding the secret
$TenantId = '<TenantID>'
$ClientId = '<AppClientID>'
$Secret   = Get-Secret -Name 'GraphReportApp' -AsPlainText

function Get-GraphToken {
    $body = @{
        client_id     = $ClientId
        scope         = 'https://graph.microsoft.com/.default'
        client_secret = $Secret
        grant_type    = 'client_credentials'
    }
    $tokenUri = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"
    (Invoke-RestMethod -Method Post -Uri $tokenUri -Body $body `
        -ContentType 'application/x-www-form-urlencoded').access_token
}

function Invoke-GraphPaged {
    param([Parameter(Mandatory)][string]$Uri, [Parameter(Mandatory)][hashtable]$Headers)
    $all = [System.Collections.Generic.List[object]]::new()
    do {
        $page = Invoke-RestMethod -Method Get -Uri $Uri -Headers $Headers
        foreach ($item in $page.value) { $all.Add($item) }
        $Uri = $page.'@odata.nextLink'
    } while ($Uri)
    $all
}

$headers = @{ Authorization = "Bearer $(Get-GraphToken)" }

# Single quotes matter: in double quotes PowerShell would try to expand $select
$users = Invoke-GraphPaged -Headers $headers `
    -Uri 'https://graph.microsoft.com/v1.0/users?$select=displayName,userPrincipalName,accountEnabled&$top=999'

$devices = Invoke-GraphPaged -Headers $headers `
    -Uri 'https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?$select=deviceName,operatingSystem,osVersion,lastSyncDateTime,userPrincipalName'

"{0} users, {1} managed devices" -f $users.Count, $devices.Count
$devices | Sort-Object lastSyncDateTime |
    Select-Object deviceName, operatingSystem, osVersion, lastSyncDateTime, userPrincipalName |
    Export-Csv -Path .\managed-devices.csv -NoTypeInformation

If you'd rather use a certificate

Doing certificate auth with raw Invoke-RestMethod means building and signing a JWT client assertion yourself. It's doable but fiddly, so when we use certificates we let the Microsoft Graph PowerShell SDK do that part:

# Certificate auth with the Graph SDK - no secret on disk at all
Connect-MgGraph -TenantId '<TenantID>' -ClientId '<AppClientID>' -CertificateThumbprint '<Thumbprint>'
Get-MgDeviceManagementManagedDevice -All | Select-Object DeviceName, OperatingSystem, LastSyncDateTime

It's the same app registration with the same permissions. You just upload the public key (.cer) under Certificates & secrets.

Checking the token before you trust the report

$t = Get-GraphToken
$t.Length            # a long string, not an error
($t -split '\.')[1]  # middle segment of the JWT - decode it to check the "roles" claim
  • Decode the middle part of the token with a JWT decoder you trust, or base64-decode it locally. The roles claim should list User.Read.All and DeviceManagementManagedDevices.Read.All. If roles is missing, consent hasn't been granted.
  • Compare the device count in the CSV with the count in the Intune admin center. If yours comes out at exactly 100, 999 or 1000, paging is broken.

Things that have bitten us

  • Double-quoted URLs. "...users?$select=..." expands $select to an empty string, and Graph happily returns every property. Use single quotes, or escape the dollar sign with a backtick.
  • Long runs outlive the token. A big tenant can take more than an hour to page through, so on long jobs request a fresh token before the old one expires instead of reusing one for the whole run.
  • Throttling. Push Graph too hard and it returns 429 with a Retry-After header. Wrap your calls in a retry that honours that header rather than hammering it.
  • Expired secrets. This is the most common "the report stopped" ticket we get. The error is AADSTS7000222 (expired client secret). Track expiry dates somewhere you actually look.
  • Adding the Delegated version of a permission looks right in the portal and does nothing for this flow.

Create an account or sign in to comment

Search

Search

Configure browser push notifications

Chrome (Android)
  1. Tap the lock icon next to the address bar.
  2. Tap Permissions → Notifications.
  3. Adjust your preference.
Chrome (Desktop)
  1. Click the padlock icon in the address bar.
  2. Select Site settings.
  3. Find Notifications and adjust your preference.