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*) andAzureADare 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 on429 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.PowerShellnow 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-Modulefrom 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, orConnect-AzureAD/Get-AzureADUserin your scripts, they are running on borrowed time — both modules are retired and unsupported. The migration target is always the Graph SDK. UseFind-MgGraphCommandto find the Graph equivalent of a legacy cmdlet.
1.2 Choosing your module and auth path
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.PowerShellnow requires you to register and consent to your own app — runRegister-PnPEntraIDApp(orRegister-PnPEntraIDAppForInteractiveLogin) once to provision it.
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
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
signInActivityrequires 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-ExchangeOnlineapp-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.Allis 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.
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 —
MSOnlineandAzureADare 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:
-Allfor pagination,Retry-Afterfor throttling, and$batchfor 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
- Inventory your legacy scripts for
*-Msol*and*-AzureAD*cmdlets and plan their migration to Graph usingFind-MgGraphCommand. - Register one automation app with a certificate and the minimum application permissions, then port a single read-only report to app-only auth.
- Move that report into an Azure Automation runbook with a managed identity to prove out the secretless pattern end-to-end.
- Add the throttling wrapper from §4.2 to your shared automation module so every script inherits safe retry behaviour.
- 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/
Leave a Reply