This guide assumes Powershell 7+, the Microsoft Graph PowerShell SDK v2 installed, and an Entra tenant where you can either consent to delegated scopes or register an app for client-credentials flow.

# v1.0 endpoints
Install-Module Microsoft.Graph -Scope CurrentUser -Repository PSGallery -Force

# Beta endpoints (separate module in v2 - optional, install if you need beta)
Install-Module Microsoft.Graph.Beta -Scope CurrentUser -Repository PSGallery -Force

SDK v1 vs v2. The examples in this post target Microsoft.Graph v2+. The biggest breaking change from v1: Select-MgProfile is gone beta endpoints now live in a separate Microsoft.Graph.Beta module (with cmdlets like Get-MgBetaUser) or are accessed directly via Invoke-MgGraphRequest with a /beta/ URL. If you still have v1 installed, Uninstall-Module Microsoft.Graph -AllVersions and reinstall.

The old MSOnline, AzureAD, AzureADPreview, MSGraph-Intune, and the original Defender ATP modules are all deprecated. Microsoft's answer is Microsoft Graph one unified REST API for everything in Entra, Intune, Defender XDR, Exchange Online, Teams, and SharePoint. The official Microsoft.Graph Powershell SDK is auto-generated from the Graph metadata, which makes it complete, enormous, and occasionally slow.

This post is the practical layer on top: the auth patterns that actually scale, the SDK quirks that aren't in the docs, and advanced examples for the three workloads most teams hit first Entra, Intune, and Defender.

Rule of thumb: for ad-hoc work, use the typed cmdlets (Get-MgUser). For automation that needs to be fast and predictable, call the Graph REST API directly with Invoke-MgGraphRequest same auth, half the surface area, ten times the throughput.

Auth: Two Patterns

Interactive / Delegated (a person's permissions)

Connect-MgGraph -Scopes @(
    'User.Read.All',
    'Group.Read.All',
    'DeviceManagementManagedDevices.Read.All',
    'SecurityEvents.Read.All'
)

A browser window opens, the user consents, the SDK caches a token in the local profile. Useful for development and one-off work useless for unattended jobs.

App-Only / Client Credentials (a service principal's permissions)

The right pattern for any scheduled or hosted automation:

  1. Register an app in Entra (Microsoft Entra ID -> App registrations -> New registration).
  2. Add Application permissions on Microsoft Graph (e.g. User.Read.All, DeviceManagementManagedDevices.Read.All, SecurityEvents.Read.All). Grant admin consent.
  3. Add a certificate (preferred) or client secret.

Then connect:

# Certificate-based - preferred (cert must live in Cert:\CurrentUser\My or Cert:\LocalMachine\My)
$tenantInfo = @{
    TenantId              = $env:TENANT_ID
    ClientId              = $env:CLIENT_ID 
    CertificateThumbprint = $env:CERT_THUMBPRINT
}
Connect-MgGraph @tenantInfo

# Secret-based - works, but rotates more often
$secret = ConvertTo-SecureString $env:CLIENT_SECRET -AsPlainText -Force
$cred = [pscredential]::new($env:CLIENT_ID, $secret)
Connect-MgGraph -TenantId $env:TENANT_ID -ClientSecretCredential $cred

Use a certificate, not a secret. Secrets get committed to git; certs live in KeyVault and rotate cleanly. The CI patterns in the publishing post apply directly here.

Managed identity (Azure-hosted workloads)

If your script runs on an Azure VM, Function, Automation account, or Container App with a managed identity attached, skip certificates and secrets entirely:

# System-assigned managed identity
Connect-MgGraph -Identity

# User-assigned managed identity
Connect-MgGraph -Identity -ClientId '<USER_ASSIGNED_MI_CLIENT_ID>'

The managed identity needs the relevant Graph application permissions assigned to its service principal via PowerShell (the Azure portal's "API permissions" blade does not cover this path).

Always Pin the API Version

The typed cmdlets in Microsoft.Graph hit v1.0 endpoints. Some interesting things Defender XDR alerts, Intune compliance details, conditional access insights, the MFA registration report only exist on beta. In SDK v2, there are two ways to reach them.

Option A: Use the Microsoft.Graph.Beta module

Install-Module Microsoft.Graph.Beta -Scope CurrentUser -Repository PSGallery -Force
Import-Module  Microsoft.Graph.Beta.Users

Get-MgBetaUser -Filter "accountEnabled eq true" -Top 10

Every Get-MgUser / New-MgGroup / Update-MgDevice style cmdlet has a Get-MgBetaUser / New-MgBetaGroup / Update-MgBetaDevice counterpart in the Beta module. Install only the submodule you need (e.g. Microsoft.Graph.Beta.Users) to avoid pulling down all ~47 Beta submodules.

Option B: Call the raw URL with Invoke-MgGraphRequest

Invoke-MgGraphRequest -Method GET -Uri 'https://graph.microsoft.com/beta/security/alerts_v2'

This is what every example in the rest of the post does. It works for both v1.0 and beta by changing the URL segment, needs no separate module install, and gives you the most control over $select, $filter, and $top.

Select-MgProfile is gone in SDK v2. If you have older scripts calling it, replace with the Beta module import or an Invoke-MgGraphRequest call to the beta URL. Don't mix v1.0 and beta cmdlets for the same entity in the same script pick one per script and stick with it.

Paging That Actually Works

Graph returns at most 100–999 results per page (the cap varies per resource). The SDK cmdlets handle paging automatically only if you ask:

# Wrong - only the first page
$users = Get-MgUser

# Right - all pages
$users = Get-MgUser -All

# Right - page-by-page, streaming
Get-MgUser -All -PageSize 999 |
    ForEach-Object { Process-User $_ }

For the raw API, page yourself:

function Get-AllGraph
{
    param([string]$Uri)
    do
    {
        $resp = Invoke-MgGraphRequest -Method GET -Uri $Uri
        $resp.value
        $Uri = $resp.'@odata.nextLink'
    } while ($Uri)
}

Get-AllGraph 'https://graph.microsoft.com/v1.0/users?$top=999&$select=id,displayName,userPrincipalName'

The @odata.nextLink already encodes the next page never recompute it from skip tokens. If it's there, follow it. If it isn't, you're done.

Batching The Single Biggest Speedup

Graph supports up to 20 requests per batch. For workloads where you'd otherwise issue thousands of small calls, batching turns minutes into seconds:

function Invoke-GraphBatch
{
    [CmdletBinding()]
    param([Parameter(Mandatory)] [object[]]$Requests)

    $results = New-Object System.Collections.Generic.List[object]

    # Graph caps each batch at 20 requests
    for ($i = 0; $i -lt $Requests.Count; $i += 20)
    {
        $chunk = $Requests[$i..([Math]::Min($i + 19, $Requests.Count - 1))]

        $body = @{
            requests = @($chunk | ForEach-Object {
                [pscustomobject]@{
                    id     = $_.id
                    method = $_.method
                    url    = $_.url
                }
            })
        } | ConvertTo-Json -Depth 6

        $batchParams = @{
            Method      = 'POST'
            Uri         = 'https://graph.microsoft.com/v1.0/$batch'
            Body        = $body
            ContentType = 'application/json'
        }
        $resp = Invoke-MgGraphRequest @batchParams

        $results.AddRange($resp.responses)
    }
    $results
}

Use it like this get the manager for 1,000 users in seconds:

$users    = Get-MgUser -All -Property id,displayName
$requests = $users | ForEach-Object {
    @{ id = $_.Id; method = 'GET'; url = "/users/$($_.Id)/manager" }
}

$managers = Invoke-GraphBatch -Requests $requests

Always include an id per request in the batch. The responses come back unordered the id is how you correlate. Re-using the user's GUID as the request id is the cleanest pattern.

Retry and Throttling

Graph throttles at the tenant + resource level. The official guidance: respect Retry-After. A small wrapper handles the common cases:

function Invoke-GraphWithRetry
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string]$Method,
        [Parameter(Mandatory)] [string]$Uri,
        [object]$Body,
        [int]$MaxAttempts = 5
    )

    for ($i = 1; $i -le $MaxAttempts; $i++)
    {
        try
        {
            $params = @{ Method = $Method; Uri = $Uri }
            if ($Body)
            {
                $params.Body        = ($Body | ConvertTo-Json -Depth 10)
                $params.ContentType = 'application/json'
            }
            return Invoke-MgGraphRequest @params
        } catch {
            $resp = $_.Exception.Response
            $code = $resp.StatusCode.Value__

            if ($code -in 429, 503)
            {
                $wait = [int]($resp.Headers['Retry-After'] ?? (5 * $i))
                Write-Verbose "Throttled ($code). Sleeping $wait s (attempt $i/$MaxAttempts)"
                Start-Sleep -Seconds $wait
                continue
            }
            throw
        }
    }
    throw "Graph call failed after $MaxAttempts attempts: $Method $Uri"
}

Wrap any high-volume operation with this. The default SDK cmdlets do retry, but they back off conservatively for batch-driven scripts, the explicit version is friendlier.

Entra (Azure AD) Examples

Find Users Without MFA Registered

$uri = 'https://graph.microsoft.com/beta/reports/authenticationMethods/userRegistrationDetails?$filter=isMfaRegistered eq false'
$noMfa = Get-AllGraph $uri |
    Where-Object { $_.userType -eq 'member' -and -not $_.isAdmin }

$noMfa | Select-Object userPrincipalName, lastUpdatedDateTime |
    Export-Csv ./users-no-mfa.csv -NoTypeInformation

The MFA registration report lives on beta and only fills in if the tenant is on Entra ID P1+. On free tenants the endpoint returns 403.

Bulk Disable Inactive Accounts

$cutoff = (Get-Date).AddDays(-90).ToString('o')
$uri    = "https://graph.microsoft.com/beta/users?`$filter=signInActivity/lastSignInDateTime le $cutoff and accountEnabled eq true&`$select=id,userPrincipalName,signInActivity&`$top=999"

$inactive = Get-AllGraph $uri

$requests = $inactive | ForEach-Object {
    @{
        id     = $_.id
        method = 'PATCH'
        url    = "/users/$($_.id)"
        body   = @{ accountEnabled = $false }
        headers = @{ 'Content-Type' = 'application/json' }
    }
}

# Note: PATCH bodies need to be passed inside the batch entries
foreach ($r in $requests) { $r.body = $r.body }

# Use the batch helper, but extend it to forward bodies/headers

signInActivity is on beta and requires AuditLog.Read.All. Without that scope the property is silently null on every result.

Conditional Access Export Policies for Source Control

$policies = Get-AllGraph 'https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies'

$policies | ForEach-Object {
    $name = ($_.displayName -replace '[\\/:*?"<>|]', '_')
    $_ | ConvertTo-Json -Depth 20 | Set-Content "./ca-policies/$name.json"
}

Commit ./ca-policies/ to git. Now every CA change becomes a reviewable diff and the same JSON can be replayed via POST /identity/conditionalAccess/policies to a sandbox tenant for testing.

Privileged Role Assignment Audit

$roles = Get-AllGraph 'https://graph.microsoft.com/v1.0/directoryRoles'

$roles | ForEach-Object {
    $r = $_
    $members = Get-AllGraph "https://graph.microsoft.com/v1.0/directoryRoles/$($r.id)/members"
    foreach ($m in $members)
    {
        [pscustomobject]@{
            Role             = $r.displayName
            Member           = $m.displayName
            UPN              = $m.userPrincipalName
            Type             = $m.'@odata.type' -replace '#microsoft.graph.', ''
        }
    }
} | Export-Csv ./role-assignments.csv -NoTypeInformation

Intune Examples

Stale Device Cleanup

Connect-MgGraph -Scopes 'DeviceManagementManagedDevices.ReadWrite.All'

$stale = Get-AllGraph 'https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?$filter=lastSyncDateTime le 2024-12-01T00:00:00Z&$top=1000'

foreach ($d in $stale)
{
    Write-Host "Retiring $($d.deviceName) (last sync $($d.lastSyncDateTime))"
    $retire = @{
        Method = 'POST'
        Uri    = "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices/$($d.id)/retire"
    }
    Invoke-GraphWithRetry @retire
}

retire is the soft action wipes corporate data, leaves personal data on BYOD devices. For company-owned devices use wipe. Both are POSTs with empty bodies.

Compliance Report Across All Devices

$devices = Get-AllGraph 'https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?$select=id,deviceName,osVersion,operatingSystem,complianceState,userPrincipalName'

$nonCompliant = $devices | Where-Object complianceState -ne 'compliant'

# Get the *reasons* - separate call per device
$requests = $nonCompliant | ForEach-Object {
    @{ id = $_.id; method = 'GET'; url = "/deviceManagement/managedDevices/$($_.id)/deviceCompliancePolicyStates" }
}

$states = Invoke-GraphBatch -Requests $requests

# Cross-reference
$report = foreach ($d in $nonCompliant) {
    $resp = $states | Where-Object id -eq $d.id | Select-Object -First 1
    $reasons = ($resp.body.value | Where-Object state -ne 'compliant').displayName -join '; '
    [pscustomobject]@{
        Device       = $d.deviceName
        User         = $d.userPrincipalName
        OS           = "$($d.operatingSystem) $($d.osVersion)"
        Reasons      = $reasons
    }
}

$report | Export-Csv ./non-compliant.csv -NoTypeInformation

Push a Configuration Profile to a New Group

$assignment = @{
    assignments = @(
        @{
            target = @{
                '@odata.type' = '#microsoft.graph.groupAssignmentTarget'
                groupId       = $targetGroupId
            }
        }
    )
} | ConvertTo-Json -Depth 5

$assignParams = @{
    Method      = 'POST'
    Uri         = "https://graph.microsoft.com/beta/deviceManagement/deviceConfigurations/$configId/assign"
    Body        = $assignment
    ContentType = 'application/json'
}
Invoke-MgGraphRequest @assignParams

The Intune assignment endpoints are POST + /assign, not PATCH on the resource itself. Easy to get wrong the difference shows up as "the API returned 200 but nothing happened" because PATCHing the resource silently ignores the assignments property.

App Install Status Across the Fleet

$appId = '<intune-mobile-app-id>'

$installs = Get-AllGraph "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$appId/deviceStatuses"

$installs | Group-Object installState | Select-Object Name, Count

For the per-user breakdown swap deviceStatuses for userStatuses. Useful for "the rollout is at 87%, here's the 13% that haven't picked it up yet."

Defender XDR Examples

Open Alerts in the Last 24 Hours

Connect-MgGraph -Scopes 'SecurityAlert.Read.All'

$since = (Get-Date).ToUniversalTime().AddHours(-24).ToString('o')
$uri   = "https://graph.microsoft.com/beta/security/alerts_v2?`$filter=createdDateTime ge $since and status eq 'new'&`$top=200"

$alerts = Get-AllGraph $uri

$alerts | Select-Object title, severity, classification, assignedTo, createdDateTime |
    Sort-Object severity, createdDateTime -Descending |
    Format-Table -AutoSize

Bulk Update Alert Status

$idsToClose = Import-Csv ./benign-alerts.csv | Select-Object -ExpandProperty id

$requests = $idsToClose | ForEach-Object {
    @{
        id     = $_
        method = 'PATCH'
        url    = "/security/alerts_v2/$_"
        body   = @{
            status         = 'resolved'
            classification = 'falsePositive'
            determination  = 'notMalicious'
        }
        headers = @{ 'Content-Type' = 'application/json' }
    }
}

# (Use the batch helper extended to forward bodies + headers)

Run an Advanced Hunting Query

The runHuntingQuery endpoint runs KQL across the Defender data lake the same queries you write in the portal:

$kql = @"
DeviceProcessEvents
| where Timestamp > ago(1d)
| where FileName =~ "powershell.exe"
| where ProcessCommandLine has_any ("encodedcommand", "frombase64string")
| project Timestamp, DeviceName, AccountName, ProcessCommandLine
| top 100 by Timestamp desc
"@

$body = @{ Query = $kql } | ConvertTo-Json

$huntParams = @{
    Method      = 'POST'
    Uri         = 'https://graph.microsoft.com/beta/security/runHuntingQuery'
    Body        = $body
    ContentType = 'application/json'
}
$results = Invoke-MgGraphRequest @huntParams

$results.results |
    ConvertTo-Json -Depth 5 |
    Set-Content "./hunt-$(Get-Date -Format 'yyyyMMdd-HHmmss').json"

This pairs naturally with the hunting post: write the detection in Powershell against local logs, validate it works, then promote the same logic into a KQL hunting query that runs across the whole Defender estate.

Isolate a Compromised Device

$isolateParams = @{
    Method = 'POST'
    Uri    = "https://graph.microsoft.com/beta/deviceManagement/managedDevices/$deviceId/isolate"
    Body   = @{ isolationType = 'full' }
}
Invoke-GraphWithRetry @isolateParams

full blocks all network traffic except Defender management. selective allows a small set of approved corporate apps. Always document who triggered the isolation in the alert audit trail before doing this automated containment is great, but explainability matters more.

Putting It All Together A Daily Health Job

A scheduled job that produces a single JSON blob your dashboards can consume:

$connect = @{
    TenantId              = $env:TENANT_ID
    ClientId              = $env:CLIENT_ID
    CertificateThumbprint = $env:CERT
}
Connect-MgGraph @connect

# No profile switch needed - each call below targets v1.0 or beta via the URL itself.

$report = [ordered]@{
    GeneratedAt              = (Get-Date).ToString('o')
    UsersTotal               = (Get-AllGraph 'https://graph.microsoft.com/v1.0/users?$count=true&$top=1' -Headers @{'ConsistencyLevel'='eventual'}).'@odata.count'
    UsersNoMfa               = (Get-AllGraph 'https://graph.microsoft.com/beta/reports/authenticationMethods/userRegistrationDetails?$filter=isMfaRegistered eq false' | Measure-Object).Count
    DevicesNonCompliant      = (Get-AllGraph 'https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?$filter=complianceState ne ''compliant''' | Measure-Object).Count
    AlertsOpenLast24h        = (Get-AllGraph "https://graph.microsoft.com/beta/security/alerts_v2?`$filter=createdDateTime ge $((Get-Date).AddHours(-24).ToString('o')) and status eq 'new'" | Measure-Object).Count
    StaleDevices90d          = (Get-AllGraph "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$filter=lastSyncDateTime le $((Get-Date).AddDays(-90).ToString('o'))" | Measure-Object).Count
}

$report | ConvertTo-Json | Set-Content "./graph-daily-$(Get-Date -Format 'yyyy-MM-dd').json"

Schedule it to run before the morning standup. Pipe the JSON into Zabbix using the process logging UserParameter pattern and you have a free M365 health dashboard.

Operational Notes

  • Never log the access token. The SDK exposes it via Get-MgContext; treat it like a password. Tokens are typically valid for 60 minutes long enough to do real damage if leaked.
  • Pin SDK versions in CI. The Microsoft.Graph modules ship breaking changes more often than you'd expect for a 1.x SDK. Install-Module Microsoft.Graph -RequiredVersion 2.x.y is a good habit.
  • Set User-Agent on raw calls for tenant-side observability the value shows up in the MicrosoftGraphActivityLogs table and helps your security team distinguish your scripts from a stolen token.
  • Use $select on every query. Returning every property of every user is gigabytes of needless data and counts against your throttling budget.
  • Watch for SDK cmdlets that don't pass through $filter. Some Get-Mg* cmdlets fetch all data and filter client-side. Add -Debug to the cmdlet call (or Connect-MgGraph -Debug for the whole session) to see the underlying HTTP request and verify the $filter actually reaches the server. Fall back to Invoke-MgGraphRequest when the cmdlet is doing it wrong.
  • Delegated vs application scope matches the permission type on your app registration. A certificate-authenticated app with Application User.Read.All can read every user; an interactive user token with the same Delegated scope can only read users the signed-in account has rights to see. Mismatching these is the second-most-common Graph script bug.

What to Do Next

The Microsoft Graph SDK is the way forward, but it's also the way to write very slow PowerShell if you take the cmdlets at face value. Use the typed cmdlets when you're exploring; switch to Invoke-MgGraphRequest plus batching the moment a script needs to scale.

Five habits to lock in:

  1. Authenticate with a certificate (KeyVault-backed in CI), never a secret committed to a config file.
  2. Page everything: pass -All to typed cmdlets or follow @odata.nextLink for raw calls.
  3. Retry on 429 with the exponential backoff helper from the post.
  4. Pin your API version: do not mix Microsoft.Graph v1.0 cmdlets and Microsoft.Graph.Beta cmdlets in the same script.
  5. $select only what you need: a single Graph call with $select=id,displayName is several times faster than the unfiltered version.

Three immediate moves:

  1. Find the slowest Graph script in your automation and add a Measure-Command wrapper. If the bulk of the time is in the typed cmdlets, refactor to Invoke-GraphBatch from the post; you'll typically see 10x.
  2. Audit any ClientSecret-based authentication. Move the credential to a certificate in KeyVault before next quarter's rotation deadline.
  3. For the next reporting run, swap Get-MgUser -All for Invoke-MgGraphRequest with $select and pagination. Compare runtime; the answer changes how you write the next ten scripts.

Pairs naturally with the secret-management post (where the certificate thumbprint and tenant ID actually live) and the parallelism post (Graph batching is the I/O-bound case -Parallel was made for).