A module migration is not search and replace
AzureAD, AzureADPreview and MSOnline are deprecated. Microsoft Graph PowerShell is their successor, but renaming commands does not make old scripts compatible. Parameters, permissions, filters, pagination and returned object shapes can all change.
The greatest risk sits in unattended scripts that create accounts, assign licences, manage groups, revoke sessions or produce scheduled reports. A safe migration inventories dependencies, reproduces reads first, compares results, then moves writes behind a dry-run control and rollback procedure.
1. Inventory scripts and rank their risk
Search repositories, scheduled tasks, runbooks, management servers and service-desk tools for AzureAD and MSOnline imports, connections and commands. Record the owner, schedule, execution identity, write operations, permissions and impact of failure for each script.
Migrate simple reports before critical automation. A read-only script lets the team learn Graph objects without risking a removed licence or an unintended group change.
$root = 'C:\Scripts'
$patterns = 'AzureAD','MSOnline','Connect-AzureAD','Connect-MsolService',`
'Get-AzureAD','Set-AzureAD','New-AzureAD','Remove-AzureAD',`
'Get-Msol','Set-Msol','New-Msol','Remove-Msol'
Get-ChildItem $root -Filter *.ps1 -Recurse -File |
Select-String -Pattern $patterns -SimpleMatch |
Select-Object Path,LineNumber,Line |
Export-Csv "$root\graph-migration-inventory.csv" -NoTypeInformation -Encoding UTF82. Install only the modules you need
PowerShell 7 is recommended, although the SDK also supports Windows PowerShell 5.1. The complete Microsoft.Graph package contains dozens of submodules. On automation hosts, install only the modules in use to reduce loading time and keep updates controlled.
Prefer the v1.0 API in production. Microsoft.Graph.Beta is separate and its contracts may change. Use it only for a required feature that is not available in v1.0 and isolate that dependency in code.
Install-Module Microsoft.Graph.Authentication -Scope CurrentUser
Install-Module Microsoft.Graph.Users -Scope CurrentUser
Install-Module Microsoft.Graph.Groups -Scope CurrentUser
Install-Module Microsoft.Graph.Identity.SignIns -Scope CurrentUser
Find-MgGraphCommand -Command Get-MgUser
Find-MgGraphCommand -Uri '/users/{id}' -Method GET
Find-MgGraphPermission -SearchString 'Get-MgUser'3. Match authentication to the workload
Connect-MgGraph uses MSAL and supports delegated and application permissions. Unattended work must not depend on an administrator's personal token. Prefer a managed identity where supported, otherwise use a dedicated application and certificate. Do not reuse one application for every script because a compromise would expose their combined permissions.
| Scenario | Method | Security choice |
|---|---|---|
| Interactive administration | Delegated permissions | Minimum scopes, human sign-in and Process context |
| Azure Automation or Azure resource | Managed identity | No distributed secret; minimum application permissions |
| Scheduled task outside Azure | Application and certificate | Protected private key, documented rotation and admin consent |
| Plain-text client secret | Avoid | Easy to copy, expire or expose in logs |
Connect-MgGraph -Scopes 'User.Read.All','Group.Read.All' -ContextScope Process
Connect-MgGraph -TenantId $TenantId -ClientId $ClientId `
-CertificateThumbprint $Thumbprint -NoWelcome
Connect-MgGraph -Identity -NoWelcome
Get-MgContext | Select-Object TenantId,ClientId,AuthType,Scopes,ContextScope4. Map commands and permissions
Microsoft's command map is a starting point, not proof of equivalence. Some capabilities exist only in Graph Beta, while others have no direct replacement. Read the documentation and underlying API for every command, then choose the least privileged permission that supports the operation.
| Legacy command | Graph command | Validate |
|---|---|---|
| Connect-AzureAD / Connect-MsolService | Connect-MgGraph | Scopes, tenant, authentication type and context |
| Get-AzureADUser / Get-MsolUser | Get-MgUser | Explicit properties, filters and pagination |
| Get-AzureADGroup | Get-MgGroup | Group type and returned properties |
| Get-AzureADGroupMember | Get-MgGroupMember | directoryObject results; cast or retrieve type when needed |
| Add-AzureADGroupMember | New-MgGroupMemberByRef | @odata.id URI and Object ID |
| Get-AzureADSubscribedSku | Get-MgSubscribedSku | SkuId, SkuPartNumber and available units |
| Set-AzureADUserLicense | Set-MgUserLicense | addLicenses/removeLicenses arrays and rollback |
| Get-AzureADMSConditionalAccessPolicy | Get-MgIdentityConditionalAccessPolicy | Policy.Read.All or higher permissions |
| Revoke-AzureADUserAllRefreshToken | Revoke-MgUserSignInSession | Operational effect and logging |
5. Rewrite reads correctly
Get-MgUser does not return every property by default. Request every property the script consumes. Use -All when the result must span every page, and prefer a server-side filter over downloading the tenant and applying Where-Object locally.
Advanced queries using $count, certain filters or searches require ConsistencyLevel eventual. Compare results by Id or UserPrincipalName, not display order.
$users = Get-MgUser -All `
-Property Id,DisplayName,UserPrincipalName,AccountEnabled,Department `
-Filter "accountEnabled eq true"
$users | Select-Object Id,DisplayName,UserPrincipalName,AccountEnabled,Department$count = 0
$guests = Get-MgUser -All -ConsistencyLevel eventual `
-CountVariable count -Filter "userType eq 'Guest'" `
-Property Id,DisplayName,UserPrincipalName,CreatedDateTime
"Returned guests: $($guests.Count); Graph count: $count"6. Put writes behind a dry-run control
Do not assume every Graph command supports -WhatIf. Add an explicit DryRun switch, log the target and proposed values, and require an intentional action to execute. Use stable Object IDs and read back the object after critical changes.
For licences, resolve SkuPartNumber to SkuId first, retain the prior state and test with a pilot account. Removing a licence can affect services and retention. The example performs the addition only when DryRun is off.
param([string]$UserId, [string]$SkuPartNumber, [switch]$DryRun)
$sku = Get-MgSubscribedSku -All | Where-Object SkuPartNumber -eq $SkuPartNumber | Select-Object -First 1
if (-not $sku) { throw "SKU not found: $SkuPartNumber" }
$before = Get-MgUserLicenseDetail -UserId $UserId
$plan = [pscustomobject]@{ UserId=$UserId; AddSkuId=$sku.SkuId; Remove=@() }
$plan | ConvertTo-Json -Depth 5
if (-not $DryRun) {
Set-MgUserLicense -UserId $UserId `
-AddLicenses @(@{ SkuId = $sku.SkuId }) -RemoveLicenses @()
Get-MgUserLicenseDetail -UserId $UserId
}7. Compare old and new before cutover
For a short controlled period, run both versions read-only and normalize their output to the same schema. Compare object counts, identifiers and business values. Differences may reveal a missing property, unhandled page, incompatible filter or insufficient permission.
Never run two writing versions in parallel. Critical automation needs a kill switch, correlation logging, failure alert and a way to temporarily restore the legacy version without losing queued input.
$old = Import-Csv .\baseline-azuread.csv | Sort-Object Id
$new = Import-Csv .\candidate-graph.csv | Sort-Object Id
Compare-Object $old $new -Property Id,UserPrincipalName,AccountEnabled `
-PassThru | Export-Csv .\graph-differences.csv -NoTypeInformation8. Roll out in waves and monitor
- Keep the tested module versions in the deployment repository.
- Test SDK updates before deploying them to runbooks.
- Log tenant, application, operation, target and result without exposing tokens.
- Handle errors and throttling without endless retry loops.
- Remove obsolete certificates, secrets and consent after migration.
| Wave | Scope | Exit criterion |
|---|---|---|
| 0 — Inventory | Scripts, owners, modules and identities | No unknown dependency |
| 1 — Reports | Simple non-critical reads | Object and property parity |
| 2 — Pilot automation | Dedicated app and limited targets | Least privilege and recovery tested |
| 3 — Production | Migration by service | Alerts, logs and procedures operational |
| 4 — Retirement | Legacy jobs and consent | No AzureAD or MSOnline calls remain |
Checklist before removing AzureAD and MSOnline
The most reliable migration is rarely the fastest. It turns every implicit dependency into an explicit choice: which data to read, which identity acts, which permissions are required and how to roll back. That work prevents a module change from becoming a production outage.
- Every script, runbook and scheduled task has an owner.
- Each legacy command has a validated mapping or documented decision.
- Properties, pagination and filters were tested against realistic volume.
- Graph permissions follow least privilege and consent is documented.
- Unattended tasks use a managed identity or protected certificate.
- Writes have dry run, logging and rollback.
- Read results reached parity before cutover.
- Support teams can recognize and handle a Graph failure.
- Legacy modules, identities and permissions are removed only after observation.