Automating Microsoft 365 with PowerShell: The Graph SDK Way in 2026


Executive Snapshot

Category Recommendation
Primary toolset Microsoft Graph PowerShell SDK (Microsoft.Graph) for cross-workload automation
Retired modules Migrate off MSOnline and AzureAD/AzureADPreview — both are retired; use Graph
Workload modules ExchangeOnlineManagement, MicrosoftTeams, PnP.PowerShell where Graph has gaps
Attended auth Connect-MgGraph -Scopes … (delegated, least privilege) for interactive work
Unattended auth App registration + certificate (never a client secret) or managed identity
Runbook identity Connect-MgGraph -Identity from an Azure Automation managed identity — zero stored secrets
Scale patterns Always page with -All; honour Retry-After on HTTP 429; batch with $batch
Governance Conditional Access for workload identities + audit every service-principal sign-in

TL;DR

  • The Microsoft Graph PowerShell SDK is the strategic foundation. MSOnline (*-Msol*) and AzureAD are retired — rebuild any automation that still depends on them on Graph.
  • Pick auth by context: delegated scopes for interactive admin work, certificate-based app-only for scheduled jobs, and managed identity for anything running inside Azure.
  • Never put a client secret in a script. Certificates and managed identities remove the single most common cause of leaked tenant credentials.
  • Graph does not cover everything. Exchange transport rules, Teams calling policy, and granular SharePoint operations still need their dedicated modules — all of which now support certificate app-only auth.
  • Scale is a solved problem if you follow three rules: page with -All, back off on 429 Retry-After, and batch related calls. Skip them and your automation dies at a few thousand objects.

Introduction

Every IT professional reaches the same tipping point: the Microsoft 365 admin center is fine for one change to one user, but it collapses the moment you need the same change applied to four thousand. Assigning licences after an acquisition, disabling stale guest accounts, exporting a mailbox-permission report for an auditor, standing up a hundred Teams from a template — these are automation problems, and in the Microsoft cloud automation means PowerShell.

What has changed in 2026 is which PowerShell. For years the answer was a tangle of single-purpose modules — MSOnline, AzureAD, the SharePoint Management Shell — each with its own cmdlet nouns, its own auth quirks, and its own gaps. That era is over. MSOnline and AzureAD are retired, and the Microsoft Graph PowerShell SDK is the unified, REST-backed surface that replaces them. Learn it once and you can reach Entra ID, Exchange, Teams, SharePoint, Intune, and Purview through a single, consistent command grammar.

This guide is the practical playbook: how the module landscape actually fits together in 2026, how to authenticate correctly for both interactive and unattended scenarios, a set of copy-paste automation recipes that solve real problems, and the production patterns — pagination, throttling, governance — that keep your scripts alive at scale. Every command here is written to run against a real tenant.


Prerequisites

  • PowerShell 7.4+ (recommended). The Graph SDK runs on Windows PowerShell 5.1, but PnP.PowerShell now requires PowerShell 7.2 or later, so standardise on 7.x.
  • Entra ID roles appropriate to the task — Global Reader for read-only reporting, and a scoped admin role (e.g. License Administrator, Exchange Administrator) for writes. Avoid using Global Administrator for routine automation.
  • An app registration in Microsoft Entra ID for any unattended/app-only scenario, with a certificate (not a secret) and admin-consented application permissions.
  • Module install rights on the workstation or runbook host (Install-Module from the PowerShell Gallery).
  • Comfort with Try/Catch — see the companion article on PowerShell error handling linked at the end.

1. The Modern Foundation: the Graph PowerShell SDK

The Microsoft Graph PowerShell SDK is an auto-generated wrapper over the Graph REST API. Because it is generated from the API itself, it covers an enormous surface — users, groups, devices, sign-in logs, mail, sites, and more — with predictable Verb-Mg* naming.

Install the meta-module (which pulls in every sub-module) once, or install only the sub-modules you need to keep things lean:

# Full SDK (large — installs all sub-modules)
Install-Module Microsoft.Graph -Scope CurrentUser

# Leaner: install only what you use
Install-Module Microsoft.Graph.Authentication, Microsoft.Graph.Users, `
               Microsoft.Graph.Identity.DirectoryManagement -Scope CurrentUser

# Beta endpoints (newer features, less stable contract)
Install-Module Microsoft.Graph.Beta -Scope CurrentUser

1.1 Where Graph stops and workload modules begin

Graph is the default, but it is not a complete replacement for every admin surface. Use this table to choose the right tool per workload:

Workload Use this Why
Users, groups, licences, devices, sign-in logs Microsoft.Graph Fully covered; the unified default
Exchange transport rules, mailbox config, retention ExchangeOnlineManagement Graph mail APIs ≠ Exchange admin; EXO module is authoritative
Teams policies, calling, voice MicrosoftTeams Policy/voice cmdlets not exposed in Graph
SharePoint/OneDrive site & permission operations PnP.PowerShell Granular site, list, and sharing control
Tenant-level SharePoint admin Microsoft.Online.SharePoint.PowerShell Classic tenant settings
Intune device management Microsoft.Graph.DeviceManagement Intune is a first-class Graph citizen

Retirement watch: If you still see Connect-MsolService, Get-MsolUser, or Connect-AzureAD/Get-AzureADUser in your scripts, they are running on borrowed time — both modules are retired and unsupported. The migration target is always the Graph SDK. Use Find-MgGraphCommand to find the Graph equivalent of a legacy cmdlet.

1.2 Choosing your module and auth path

Automation taskWhichworkload?Microsoft.GraphExchangeOnlineManagementMicrosoftTeamsPnP.PowerShellAttended orunattended?«AzureActiveDirectory»Delegated[Connect-MgGraph -Scopes]«AzureActiveDirectory»App-only certificate[Scheduled, outside Azure]«AzureActiveDirectory»Managed identity[Runs inside Azure]Users, licences,groups, devicesMailboxes,transport rulesTeams policy /voiceSharePoint /OneDriveInteractive adminScheduledoutside AzureInside Azure

The rest of this guide follows that decision tree: first authentication, then per-workload recipes, then the production patterns that apply to all of them.


2. Authentication Done Right

Authentication is where most Microsoft 365 automation projects succeed or fail. Get the model right and everything downstream is straightforward; get it wrong and you either can't run unattended or you leak a credential.

2.1 Delegated vs application permissions

There are two permission models, and conflating them is the most common Graph mistake:

Delegated Application
Acts as The signed-in user The app itself (no user)
Effective rights Intersection of user rights and consented scope Exactly the consented app role
Used for Interactive admin sessions Unattended jobs, runbooks
Consent User or admin Admin only
Example scope User.ReadWrite.All (delegated) User.ReadWrite.All (application)

A delegated session can never do more than the signed-in user can. An app-only session does exactly what its application permissions allow, regardless of any user — which is precisely why it is right for unattended automation and why those permissions must be scoped tightly.

2.2 Interactive sign-in (for development and ad-hoc admin)

# Least-privilege scopes — request only what this session needs
Connect-MgGraph -Scopes 'User.Read.All', 'Group.Read.All', 'AuditLog.Read.All'

# Confirm who/what you are connected as
Get-MgContext | Select-Object Account, TenantId, Scopes, AuthType

Not sure which scope a cmdlet needs? Ask the SDK instead of guessing:

# Which permissions does this command accept?
Find-MgGraphCommand -Command Get-MgUser | Select-Object -ExpandProperty Permissions -First 5

# Search the full permission catalogue by keyword
Find-MgGraphPermission license -PermissionType Application

2.3 App-only with a certificate (for scheduled, unattended jobs)

For anything that runs without a human — a nightly report, a scheduled task — register an app in Entra ID, grant it application permissions with admin consent, and authenticate with a certificate. Certificates beat client secrets: they don't show up in plaintext, they can be stored in the machine certificate store or Key Vault, and they're far harder to exfiltrate accidentally.

# One-time: create a self-signed cert for the automation host
$cert = New-SelfSignedCertificate -Subject 'CN=M365-Automation' `
        -CertStoreLocation 'Cert:\CurrentUser\My' `
        -KeyExportPolicy NonExportable -KeySpec Signature `
        -NotAfter (Get-Date).AddYears(2)

# Export the PUBLIC key (.cer) and upload it to the app registration
Export-Certificate -Cert $cert -FilePath '.\M365-Automation.cer'

Upload M365-Automation.cer to the app registration (Certificates & secrets → Certificates), then connect using the thumbprint:

Connect-MgGraph -ClientId  'd1f4…app-id' `
                -TenantId  'contoso.onmicrosoft.com' `
                -CertificateThumbprint '9A2B…thumbprint'

The same app + certificate works for the workload modules too:

# Exchange Online, app-only — requires the Exchange.ManageAsApp app role
Connect-ExchangeOnline -AppId 'd1f4…' -Organization 'contoso.onmicrosoft.com' `
                       -CertificateThumbprint '9A2B…'

# PnP.PowerShell, app-only (PnP needs your OWN app registration as of 2024)
Connect-PnPOnline -Url 'https://contoso-admin.sharepoint.com' `
                  -ClientId 'd1f4…' -Tenant 'contoso.onmicrosoft.com' `
                  -Thumbprint '9A2B…'

2026 gotcha: the shared multi-tenant PnP Management Shell Entra app was retired. PnP.PowerShell now requires you to register and consent to your own app — run Register-PnPEntraIDApp (or Register-PnPEntraIDAppForInteractiveLogin) once to provision it.

Automation Host(scheduled task /runbook)«AzureKeyVault»Certificate[Private key, non-exportable]«AzureActiveDirectory»App Registration[Application permissions +admin consent]Microsoft GraphExchange OnlinePnP.PowerShellLoad private keyConnect withthumbprintApp-only tokenExchange.ManageAsAppSites.* app role

2.4 Managed identity (for automation running inside Azure)

If your automation runs in Azure Automation, an Azure VM, or an Azure Function, skip certificates entirely and use a managed identity — Azure handles the credential lifecycle, so there is nothing to store, rotate, or leak.

# System-assigned managed identity
Connect-MgGraph -Identity

# User-assigned managed identity (pass its client ID)
Connect-MgGraph -Identity -ClientId 'a7c9…user-assigned-id'

Before this works, the managed identity's service principal must be granted the Graph application roles it needs. Grant them once with Graph itself:

# Run as an admin who can assign app roles
$graphSp = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'"
$miSp    = Get-MgServicePrincipal -Filter "displayName eq 'my-automation-account'"

# Example: grant User.ReadWrite.All (application)
$role = $graphSp.AppRoles | Where-Object { $_.Value -eq 'User.ReadWrite.All' }
New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $miSp.Id `
    -PrincipalId $miSp.Id -ResourceId $graphSp.Id -AppRoleId $role.Id
Automation RunbookManaged IdentityMicrosoft Entra IDMicrosoft GraphAutomation RunbookAutomation RunbookManaged IdentityManaged IdentityMicrosoft Entra IDMicrosoft Entra IDMicrosoft GraphMicrosoft Graph1Connect-MgGraph -Identity2Request token (no secret stored)3App-only access token4Authenticated context5Get-MgUser / Set-MgUserLicense6Results (paged)

3. Practical Automation Recipes

With auth solved, here are the workloads you will actually automate. Each recipe is written app-only-friendly so it works both interactively and unattended.

3.1 Bulk licence assignment

Licensing is the canonical bulk operation. Two rules trip people up: a user must have a UsageLocation before any licence can be assigned, and you assign by SKU ID, not by name.

# Find the SKU you want to assign
Get-MgSubscribedSku | Select-Object SkuPartNumber, SkuId, `
    @{N='Consumed';E={$_.ConsumedUnits}}, @{N='Total';E={$_.PrepaidUnits.Enabled}}

$skuId = (Get-MgSubscribedSku |
          Where-Object SkuPartNumber -eq 'SPE_E5').SkuId

# Assign E5 to every member of a department that doesn't already have it
$users = Get-MgUser -Filter "department eq 'Engineering'" -All `
                    -Property Id, DisplayName, UsageLocation, AssignedLicenses

foreach ($u in $users) {
    if (-not $u.UsageLocation) {
        Update-MgUser -UserId $u.Id -UsageLocation 'US'   # required first
    }
    if ($u.AssignedLicenses.SkuId -notcontains $skuId) {
        Set-MgUserLicense -UserId $u.Id `
            -AddLicenses @{ SkuId = $skuId } -RemoveLicenses @()
        Write-Host "Licensed: $($u.DisplayName)"
    }
}

The -notcontains check makes the loop idempotent — re-running it never double-assigns and never errors on users who are already licensed.

3.2 Reporting and auditing

Reporting is read-only, low-risk, and the perfect first automation. Here is a stale-account report — accounts with no interactive sign-in in 90 days:

$cutoff = (Get-Date).AddDays(-90).ToString('yyyy-MM-ddTHH:mm:ssZ')

Get-MgUser -All -Property DisplayName, UserPrincipalName, AccountEnabled, SignInActivity |
    Where-Object {
        $_.AccountEnabled -and
        $_.SignInActivity.LastSignInDateTime -lt $cutoff
    } |
    Select-Object DisplayName, UserPrincipalName,
        @{N='LastSignIn';E={$_.SignInActivity.LastSignInDateTime}} |
    Sort-Object LastSignIn |
    Export-Csv -Path '.\stale-accounts.csv' -NoTypeInformation

Reading signInActivity requires the AuditLog.Read.All permission and an Entra ID P1/P2 plan.

3.3 Exchange Online mailbox automation

Once connected app-only (section 2.3), the Exchange cmdlets behave normally. Enable the online archive for every shared mailbox:

Get-Mailbox -RecipientTypeDetails SharedMailbox -ResultSize Unlimited |
    Where-Object { -not $_.ArchiveStatus -or $_.ArchiveStatus -eq 'None' } |
    ForEach-Object {
        Enable-Mailbox -Identity $_.Identity -Archive
        Write-Host "Archive enabled: $($_.PrimarySmtpAddress)"
    }

App-only Exchange caveat: the app registration needs the Office 365 Exchange Online → Exchange.ManageAsApp application permission and a directory role (e.g. Exchange Administrator) assigned to its service principal. Granting only the Graph permission is the #1 reason Connect-ExchangeOnline app-only fails — see Troubleshooting.

3.4 SharePoint external-sharing report (PnP)

Connect-PnPOnline -Url 'https://contoso-admin.sharepoint.com' `
                  -ClientId 'd1f4…' -Tenant 'contoso.onmicrosoft.com' -Thumbprint '9A2B…'

Get-PnPTenantSite |
    Where-Object SharingCapability -ne 'Disabled' |
    Select-Object Url, Title, SharingCapability, Owner |
    Export-Csv '.\external-sharing.csv' -NoTypeInformation

3.5 The capstone: an unattended Azure Automation runbook

Tie it together with a runbook that runs on a schedule, authenticates with its managed identity, and emails a daily licence-utilisation summary — no stored credentials anywhere.

# Azure Automation runbook — managed identity, runs on a daily schedule
$ErrorActionPreference = 'Stop'

Connect-MgGraph -Identity   # system-assigned managed identity

$report = Get-MgSubscribedSku | ForEach-Object {
    [pscustomobject]@{
        Sku       = $_.SkuPartNumber
        Consumed  = $_.ConsumedUnits
        Available = $_.PrepaidUnits.Enabled - $_.ConsumedUnits
    }
}

$report | Format-Table | Out-String | Write-Output   # lands in the job output

# Optional: post to a Teams Incoming Webhook
$webhook = Get-AutomationVariable -Name 'TeamsWebhookUrl'
$body = @{ text = "Daily M365 licence report:`n$($report | ConvertTo-Json)" } | ConvertTo-Json
Invoke-RestMethod -Uri $webhook -Method Post -Body $body -ContentType 'application/json'

Disconnect-MgGraph

Import the Microsoft.Graph.Authentication and Microsoft.Graph.Identity.DirectoryManagement modules into the Automation account, assign the managed identity the Organization.Read.All Graph role, and attach a daily schedule. That is a fully serverless, secretless M365 automation.


4. Production Patterns That Keep Scripts Alive at Scale

A script that works against your 50-user test tenant can fall over against a 50,000-user production tenant. Four patterns make the difference.

4.1 Always page with -All

By default Graph returns the first page only (often 100 objects). A loop over Get-MgUser without -All silently processes a fraction of your tenant — a dangerous, invisible bug.

Get-MgUser                    # WRONG at scale — first page only
Get-MgUser -All               # RIGHT — follows @odata.nextLink automatically
Get-MgUser -All -PageSize 999 # fewer round-trips for large tenants

4.2 Honour throttling (HTTP 429)

Graph throttles aggressively. When you call the API directly with Invoke-MgGraphRequest, respect the Retry-After header instead of hammering:

function Invoke-GraphWithRetry {
    param([string]$Uri, [string]$Method = 'GET', $Body, [int]$MaxAttempts = 5)

    for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
        try {
            return Invoke-MgGraphRequest -Uri $Uri -Method $Method -Body $Body
        }
        catch {
            $status = $_.Exception.Response.StatusCode.value__
            if ($status -eq 429 -and $attempt -lt $MaxAttempts) {
                $wait = [int]($_.Exception.Response.Headers.RetryAfter.Delta.TotalSeconds)
                if (-not $wait) { $wait = [math]::Pow(2, $attempt) }   # fallback backoff
                Write-Warning "Throttled (429). Waiting $wait s (attempt $attempt)…"
                Start-Sleep -Seconds $wait
            }
            else { throw }
        }
    }
}

4.3 Batch related requests

The $batch endpoint lets you send up to 20 requests in a single round-trip — a large win when you need one call per user across thousands of users. Build a JSON batch and POST it to /$batch with Invoke-MgGraphRequest.

4.4 Error handling and idempotency

Wrap every write in Try/Catch, set $ErrorActionPreference = 'Stop', and design operations to be safely re-runnable (the -notcontains guard in 3.1 is the pattern). For the full treatment of terminating vs non-terminating errors, retry wrappers, and structured logging, see the dedicated error-handling guide linked below.


5. Security and Governance

Automation is privileged code running unattended. Treat it accordingly.

  • No plaintext secrets — ever. Use certificates or managed identities. If you genuinely must use a client secret, store it in Azure Key Vault and fetch it at runtime; never commit it.
  • Certificate over client secret. Secrets expire silently and leak in transcripts and source control; certificates can be made non-exportable and rotated centrally.
  • Conditional Access for workload identities. Entra ID lets you scope service-principal sign-in by IP/named location — lock your automation's app registration to the IP range of your runbook host.
  • Least privilege, reviewed. Grant only the application roles each job needs, and review consented permissions quarterly. An app with Directory.ReadWrite.All is a tenant-wide blast radius.
  • Audit the automation. Service-principal sign-ins appear in the Entra sign-in logs (service principal) blade; route them to Log Analytics or Microsoft Sentinel and alert on anomalies. Every Graph write is captured in the Purview audit log.

AutomationService Principal«AzureActiveDirectory»Microsoft Entra ID[Service-principal sign-in logs]«AzureLogAnalyticsWorkspace»Log Analytics[Diagnostic settings]«AzureSentinel»Microsoft Sentinel[Analytics rule + alert]SOC / On-callWorkload identitysign-inStream sign-in logsDetect anomalouslocation / timeAlert on unexpectedautomation activity

Troubleshooting

Symptom Likely Cause Fix
Connect-MgGraph -Identity fails outside Azure Managed identity only exists inside Azure resources Use certificate app-only auth on non-Azure hosts
Insufficient privileges to complete the operation Delegated session, or app missing the application role Grant + admin-consent the right app permission; reconnect
App-only Connect-ExchangeOnline returns no cmdlets Missing Exchange.ManageAsApp role or directory role on the SP Add Exchange.ManageAsApp and assign Exchange Administrator to the service principal
Script processes only ~100 objects Missing -All — only the first page was returned Add -All (and -PageSize 999) to every Get-Mg* call
Intermittent Response status code 429 Graph throttling Honour Retry-After; add the backoff wrapper from §4.2
Set-MgUserLicense fails with UsageLocation error No usage location on the user Update-MgUser -UsageLocation '<code>' before assigning
Connect-PnPOnline errors about an unknown app Retired shared PnP app Register your own app once via Register-PnPEntraIDApp

Pitfalls to Avoid

Pitfall Why it bites Do instead
Still scripting with MSOnline/AzureAD Both are retired and unsupported Rebuild on the Graph SDK; map cmdlets with Find-MgGraphCommand
Client secret pasted into the script Leaks via source control, transcripts, logs Certificate or managed identity; Key Vault if a secret is unavoidable
Over-broad app permissions "to be safe" Tenant-wide blast radius if the app is compromised Grant only the roles the job needs; review quarterly
Forgetting -All Silent partial results — the worst kind of bug Page every list call; verify counts in testing
Running automation as Global Administrator Violates least privilege; one bug = tenant-wide damage Scoped admin roles or app permissions only
Confusing delegated and application scopes App-only jobs fail or behave unexpectedly Use application permissions for unattended; delegated for interactive

Key Takeaways

  • The Microsoft Graph PowerShell SDK is the foundation for Microsoft 365 automation in 2026 — MSOnline and AzureAD are retired, so migrate.
  • Authentication is the make-or-break decision: delegated scopes interactively, certificate app-only for scheduled jobs, managed identity inside Azure.
  • Never store a secret when a certificate or managed identity will do — it eliminates the most common credential-leak path entirely.
  • Graph isn't everything: Exchange, Teams voice, and granular SharePoint still need their own modules, all of which support certificate app-only auth.
  • Scale demands discipline: -All for pagination, Retry-After for throttling, and $batch for bulk — without them, automation breaks at exactly the size where you need it.
  • Treat automation as privileged code: least-privilege app roles, Conditional Access on workload identities, and audited service-principal sign-ins.

Next Steps

  1. Inventory your legacy scripts for *-Msol* and *-AzureAD* cmdlets and plan their migration to Graph using Find-MgGraphCommand.
  2. Register one automation app with a certificate and the minimum application permissions, then port a single read-only report to app-only auth.
  3. Move that report into an Azure Automation runbook with a managed identity to prove out the secretless pattern end-to-end.
  4. Add the throttling wrapper from §4.2 to your shared automation module so every script inherits safe retry behaviour.
  5. Wire service-principal sign-in logs into Microsoft Sentinel and alert on automation running from unexpected locations.

Related Articles

  • [PowerShell Error Handling Patterns Every Sysadmin Should Use]/automation/powershell-error-handling-patterns-every-sysadmin-should-use/
  • [PowerShell 7 vs Windows PowerShell 5.1: What You Need to Know]/automation/powershell-7-vs-windows-powershell-5-1-what-you-need-to-know/
  • [Automate Windows Server Patching with PowerShell and Azure Update Manager]/automation/automate-windows-server-patching-with-powershell-and-azure-update-manager/
  • [Copilot for Microsoft 365 Rollout: Licensing, Data Readiness, and Governance]/microsoft-365/copilot-for-microsoft-365-rollout-licensing-data-readiness-and-governance/
  • [SharePoint Online Permissions and Governance: Stop Oversharing in 2026]/microsoft-365/sharepoint-online-permissions-and-governance-stop-oversharing-in-2026/
Thorsteinn Halldorsson Senior Cloud Engineer

Senior Cloud Engineer with 25+ years of hands-on experience across the datacenter-to-cloud stack: fiber SAN and disk storage, IBM/Lenovo blade and Dell/HP/Lenovo servers, Hyper-V and VMware clusters, and SQL and Remote Desktop Services (RDS) clusters. Deep in the Microsoft platform — Active Directory, PKI/certificate services, SQL, Power BI, Dynamics 365 Business Central (NAV) and AX (Axapta), Microsoft 365, Entra, and Intune — with a focus on Azure operations, FinOps, and applying AI tools like GitHub Copilot and Claude in real workflows. Writes practical, no-nonsense guides for IT professionals who need to ship real solutions.

Leave a Reply

Your email address will not be published. Required fields are marked *