Back to blog

PowerShell to Microsoft Graph API: The Complete Migration Guide for MSPs

12 min read

Microsoft retired the AzureAD and MSOnline PowerShell modules. Not deprecated — retired. MSOnline stopped working in May 2025. AzureAD followed in October 2025. If you’re an MSP still running scripts that call Connect-MsolService or Get-AzureADUser, they’re already broken. The replacement is Microsoft Graph API, and every PowerShell command you relied on has a Graph equivalent that works today.

This is the complete PowerShell to Microsoft Graph API migration reference for MSPs — every command you actually use, mapped to the new way of doing it, with working code. And for each one, we’ll show what it looks like when an AI agent handles the Graph call for you inside a runbook.

Why Microsoft Killed Your PowerShell Scripts

Here’s what happened, in order:

DateWhat Died
March 2024AzureAD and MSOnline officially deprecated
March 2025End of support — no more security patches
May 2025MSOnline stops working entirely
October 2025AzureAD stops working entirely

Both modules used the legacy Azure AD Graph API on the backend, which Microsoft also retired. The replacement is the Microsoft Graph API, accessed either through direct REST calls or the new Microsoft Graph PowerShell SDK (Microsoft.Graph module).

The new module installs in one line:

Install-Module Microsoft.Graph -Scope CurrentUser

Authentication changes too. Instead of Connect-AzureAD, you use:

Connect-MgGraph -Scopes 'User.ReadWrite.All','Group.ReadWrite.All'

You request only the permissions you need. No more broad admin tokens.

The Command Reference: PowerShell to Graph API

Every section below shows three things: the old command that’s dead, the new PowerShell cmdlet, and the raw Graph API call. The API calls matter because that’s what sits underneath everything — whether you’re writing scripts, building webhooks, or using an AI agent platform like Junto that executes these calls through its M365 and CIPP integrations.

A quick note on CIPP: if you’re already using CIPP (Cyberdrain Improved Partner Portal) to manage M365 tenants, it wraps many of these Graph API calls in a multi-tenant admin portal — bulk password resets, license management, conditional access deployment across tenants. Junto integrates with CIPP directly, which means your runbooks can leverage CIPP’s multi-tenant Graph API orchestration rather than making raw Graph calls per-tenant. If you’re managing 50+ M365 tenants, CIPP plus Junto’s automation layer is significantly more practical than scripting Graph API calls with per-tenant auth tokens.

Reset Password

The single most common ad-hoc task MSP techs handle. Someone’s locked out, you reset the password.

Old (dead):

Set-MsolUserPassword -UserPrincipalName user@client.com -NewPassword "TempP@ss1"
# or
Set-AzureADUserPassword -ObjectId $userId -Password $securePassword

New PowerShell:

$params = @{
    passwordProfile = @{
        forceChangePasswordNextSignIn = $true
        password = "TempP@ss1"
    }
}
Update-MgUser -UserId "user@client.com" -BodyParameter $params

Graph API:

PATCH https://graph.microsoft.com/v1.0/users/user@client.com
Content-Type: application/json

{
  "passwordProfile": {
    "forceChangePasswordNextSignIn": true,
    "password": "TempP@ss1"
  }
}

Permissions required: User-PasswordProfile.ReadWrite.All (delegated or application). The calling app needs at least the User Administrator Entra role.

Note: This also effectively unlocks a locked account. There’s no direct Graph API equivalent of Unlock-ADAccount — Entra ID uses Smart Lockout which auto-unlocks after a timeout, but a password reset clears the lockout immediately.

What this looks like in a runbook: Password resets are the highest-ROI runbook for MSPs. When a ticket is classified as a password reset, Junto’s AI verifies the requestor against the contact record in ConnectWise, generates a secure temporary password meeting the client’s policy, executes the Graph API reset through the M365 integration, drafts a secure delivery message, and documents the action in the ticket — all in one approval step. The tech reviews, clicks approve, and a 5-minute process becomes 30 seconds.

Create User

New hire onboarding. Create the M365 account, set the initial password, specify the UPN format.

Old (dead):

New-MsolUser -DisplayName "Sarah Chen" -UserPrincipalName sarah.chen@client.com -Password "Welcome1!"
# or
New-AzureADUser -DisplayName "Sarah Chen" -UserPrincipalName sarah.chen@client.com -PasswordProfile $passwordProfile

New PowerShell:

$params = @{
    accountEnabled = $true
    displayName = "Sarah Chen"
    mailNickname = "SarahC"
    userPrincipalName = "sarah.chen@client.com"
    passwordProfile = @{
        forceChangePasswordNextSignIn = $true
        password = "Welcome1!"
    }
}
New-MgUser -BodyParameter $params

Graph API:

POST https://graph.microsoft.com/v1.0/users
Content-Type: application/json

{
  "accountEnabled": true,
  "displayName": "Sarah Chen",
  "mailNickname": "SarahC",
  "userPrincipalName": "sarah.chen@client.com",
  "passwordProfile": {
    "forceChangePasswordNextSignIn": true,
    "password": "Welcome1!"
  }
}

Required fields: accountEnabled, displayName, mailNickname, passwordProfile, userPrincipalName. Everything else (department, jobTitle, usageLocation) is optional but recommended — you’ll need usageLocation before assigning a license.

Permissions: User.ReadWrite.All

What this looks like in a runbook: User creation is never just one API call. An onboarding runbook pulls the client’s SOP from ITGlue to determine the UPN format (first.last vs firstlast), the correct license tier, which security groups and distribution lists to add, and any client-specific MFA policy. Junto’s M365 integration executes the POST /users call, then chains the license assignment, group additions, and MFA configuration — all as a single workflow the tech approves in Slack. One ticket, one approval, six Graph API calls handled.

Disable Account

Offboarding or security incident. Block sign-in immediately.

Old (dead):

Set-MsolUser -UserPrincipalName user@client.com -BlockCredential $true
# or
Set-AzureADUser -ObjectId $userId -AccountEnabled $false

New PowerShell:

Update-MgUser -UserId "user@client.com" -AccountEnabled:$false

Graph API:

PATCH https://graph.microsoft.com/v1.0/users/user@client.com
Content-Type: application/json

{
  "accountEnabled": false
}

Set accountEnabled back to true to re-enable. Permissions: User.EnableDisableAccount.All

What this looks like in a runbook: During a security incident, speed matters. When Junto’s Sophos or SentinelOne integration detects a compromised account, an incident response runbook can disable the account via Graph, revoke active sessions (POST /users/{id}/revokeSignInSessions), pull the client’s incident response procedure from ITGlue, and notify the tech — all before anyone opens a browser. The tech approves the block, and the AI handles the chain: disable account, revoke sessions, document the action in ConnectWise, alert the client contact.

Get User Details

Pull user info for a ticket, verify account status, check license assignment.

Old (dead):

Get-MsolUser -UserPrincipalName user@client.com
# or
Get-AzureADUser -ObjectId $userId

New PowerShell:

Get-MgUser -UserId "user@client.com" -Property displayName,accountEnabled,assignedLicenses,department

Graph API:

GET https://graph.microsoft.com/v1.0/users/user@client.com?$select=displayName,accountEnabled,assignedLicenses,department

Important: Graph API only returns a default set of properties (displayName, mail, id, etc.). If you need assignedLicenses, department, or accountEnabled, you must explicitly request them with $select. This catches a lot of people migrating from the old modules, which returned everything by default.

Permissions: User.Read.All

What this looks like with an AI agent: This is what Junto’s general assistant handles constantly. A tech asks “What’s Sarah Chen’s license and MFA status at Acme Corp?” and the AI queries M365 via Graph (user details + authentication methods), pulls the device info from NinjaOne, and checks recent tickets in ConnectWise — all from one question. No PowerShell window, no switching between admin portals, no remembering which $select properties to include.

Add User to Group

New hire needs access to the Marketing SharePoint, the VPN security group, and the company-wide distribution list.

Old (dead):

Add-AzureADGroupMember -ObjectId $groupId -RefObjectId $userId
# or
Add-MsolGroupMember -GroupObjectId $groupId -GroupMemberObjectId $userId

New PowerShell:

New-MgGroupMemberByRef -GroupId $groupId -BodyParameter @{
    "@odata.id" = "https://graph.microsoft.com/v1.0/directoryObjects/$userId"
}

Graph API (single member):

POST https://graph.microsoft.com/v1.0/groups/{group-id}/members/$ref
Content-Type: application/json

{
  "@odata.id": "https://graph.microsoft.com/v1.0/directoryObjects/{user-id}"
}

Graph API (bulk — up to 20 at once):

PATCH https://graph.microsoft.com/v1.0/groups/{group-id}
Content-Type: application/json

{
  "members@odata.bind": [
    "https://graph.microsoft.com/v1.0/directoryObjects/{user-id-1}",
    "https://graph.microsoft.com/v1.0/directoryObjects/{user-id-2}",
    "https://graph.microsoft.com/v1.0/directoryObjects/{user-id-3}"
  ]
}

The bulk endpoint is a huge improvement over the old modules, which required a loop. Permissions: GroupMember.ReadWrite.All

What this looks like in a runbook: The group list is never the same across clients. Client A’s Marketing department gets added to three groups; Client B’s gets added to seven. An onboarding runbook pulls the group assignments from the client’s SOP in ITGlue and uses the bulk Graph endpoint to add the user to all of them in one call. No per-client script to maintain — the documentation drives the automation.

Remove User from Group

Offboarding, role changes, cleaning up stale group memberships.

Old (dead):

Remove-AzureADGroupMember -ObjectId $groupId -MemberId $userId

New PowerShell:

Remove-MgGroupMemberDirectoryObjectByRef -GroupId $groupId -DirectoryObjectId $userId

Graph API:

DELETE https://graph.microsoft.com/v1.0/groups/{group-id}/members/{user-id}/$ref

Critical warning: The /$ref at the end is essential. Without it, if your app has User.ReadWrite.All, the API will delete the user entirely from Entra ID — not just remove them from the group. This is one of the most dangerous migration pitfalls.

Why this matters for automation: This is exactly the kind of mistake that human-in-the-loop automation prevents. A Junto offboarding runbook constructs the correct DELETE .../members/{id}/$ref call, shows the tech exactly what will happen (“Remove jsmith from Marketing-SG, VPN-Access, AllStaff-DL”), and waits for approval before executing. The API is unforgiving — but an approval step between “proposed” and “executed” means a missing /$ref gets caught in review, not in production.

List Group Members

Audit who’s in a security group, check distribution list membership, verify access.

Old (dead):

Get-AzureADGroupMember -ObjectId $groupId
# or
Get-MsolGroupMember -GroupObjectId $groupId

New PowerShell:

Get-MgGroupMember -GroupId $groupId

Graph API:

GET https://graph.microsoft.com/v1.0/groups/{group-id}/members

Filter to only users (exclude nested groups, devices):

GET https://graph.microsoft.com/v1.0/groups/{group-id}/members/microsoft.graph.user

Returns up to 100 members per page by default (max 999 with $top). Permissions: GroupMember.Read.All

Assign M365 License

New user created — now they need an E3 or Business Premium license.

Old (dead):

Set-MsolUserLicense -UserPrincipalName user@client.com -AddLicenses "contoso:ENTERPRISEPACK"
# or
Set-AzureADUserLicense -ObjectId $userId -AssignedLicenses $licenses

New PowerShell:

Set-MgUserLicense -UserId "user@client.com" -BodyParameter @{
    addLicenses = @(
        @{
            skuId = "84a661c4-e949-4bd2-a560-ed7766fcaf2b"  # E3
            disabledPlans = @()
        }
    )
    removeLicenses = @()
}

Graph API:

POST https://graph.microsoft.com/v1.0/users/user@client.com/assignLicense
Content-Type: application/json

{
  "addLicenses": [
    {
      "skuId": "84a661c4-e949-4bd2-a560-ed7766fcaf2b",
      "disabledPlans": []
    }
  ],
  "removeLicenses": []
}

Both addLicenses and removeLicenses are required in every request — use empty arrays for whichever you’re not changing. You can also selectively disable service plans within a license (turn off Yammer but keep everything else in E3).

Find your SKU IDs:

GET https://graph.microsoft.com/v1.0/subscribedSkus

Common SKU mappings:

skuPartNumberLicense Name
ENTERPRISEPACKOffice 365 E3
SPE_E3Microsoft 365 E3
SPE_E5Microsoft 365 E5
SMB_BUSINESS_PREMIUMMicrosoft 365 Business Premium
O365_BUSINESS_PREMIUMMicrosoft 365 Business Standard
EXCHANGESTANDARDExchange Online Plan 1

Permissions: LicenseAssignment.ReadWrite.All

What this looks like in a runbook: License assignment is where per-client variation gets painful. Client A’s standard is E3. Client B uses Business Premium. Client C assigns E5 to executives and E3 to everyone else. A Junto onboarding runbook checks the client’s license matrix in ITGlue, verifies available licenses via GET /subscribedSkus through the Pax8 or CIPP integration, and assigns the correct SKU — flagging the tech if the client is out of licenses before wasting time on the rest of the onboarding. If you’re using CIPP, Junto’s integration can leverage CIPP’s multi-tenant license management to handle the assignment across tenants without per-tenant Graph API auth — one integration covers all your clients’ M365 environments.

Trigger AD Sync

Force a delta sync after making on-prem changes that need to replicate to Entra ID.

Old (still works for classic Entra Connect):

Start-ADSyncSyncCycle -PolicyType Delta

This one is different from the rest. If you’re running classic Entra Connect (the on-prem server), there’s no Graph API to trigger it remotely — you still run Start-ADSyncSyncCycle directly on the Connect server, and that cmdlet still works because it’s not part of the deprecated modules.

If you’ve moved to Entra Cloud Sync (the newer cloud-based provisioning), you can trigger it via Graph:

POST https://graph.microsoft.com/v1.0/servicePrincipals/{id}/synchronization/jobs/{jobId}/start

Permissions: Synchronization.ReadWrite.All

What this looks like in a runbook: For hybrid environments still on classic Entra Connect, the practical approach is to trigger the sync through your RMM. A Junto runbook can execute a scripted task on the Connect server via the NinjaOne integration — running Start-ADSyncSyncCycle -PolicyType Delta remotely — then verify the sync completed by checking the user’s Entra ID status through Graph. Two integrations (NinjaOne + M365) coordinated in a single workflow, no manual RDP to the sync server.

Check MFA Status

Verify a user has MFA configured, audit which methods they’re using.

Old (dead):

Get-MsolUser -UserPrincipalName user@client.com | Select StrongAuthenticationMethods

New PowerShell:

Get-MgUserAuthenticationMethod -UserId "user@client.com"

Graph API:

GET https://graph.microsoft.com/v1.0/users/user@client.com/authentication/methods

Response tells you exactly what methods are registered:

{
  "value": [
    { "@odata.type": "#microsoft.graph.passwordAuthenticationMethod" },
    { "@odata.type": "#microsoft.graph.microsoftAuthenticatorAuthenticationMethod",
      "displayName": "iPhone" },
    { "@odata.type": "#microsoft.graph.phoneAuthenticationMethod",
      "phoneNumber": "+1 4255550199" }
  ]
}

If you see only passwordAuthenticationMethod, the user has no MFA configured. Any additional method means MFA is active.

Permissions: UserAuthenticationMethod.Read.All

What this looks like in a runbook: MFA verification is a step in both onboarding and security audit runbooks. During onboarding, the runbook creates the user, assigns the license, then polls the authentication methods endpoint to confirm MFA enrollment completed before closing the ticket. During a security audit, the AI can query every user in a client’s tenant and flag anyone with only password authentication — surfacing the gap in the tech’s Slack channel without anyone writing a PowerShell loop.

Delete User (Soft Delete)

Remove a terminated employee. Graph does a 30-day soft delete by default — you can restore if needed.

Old (dead):

Remove-MsolUser -UserPrincipalName user@client.com
# or
Remove-AzureADUser -ObjectId $userId

New PowerShell:

Remove-MgUser -UserId "user@client.com"

Graph API:

DELETE https://graph.microsoft.com/v1.0/users/user@client.com

The user moves to the deleted items container for 30 days. Licenses, group memberships, and mailbox data are preserved during that window.

Restore:

POST https://graph.microsoft.com/v1.0/directory/deletedItems/{id}/restore

Permanent delete:

DELETE https://graph.microsoft.com/v1.0/directory/deletedItems/{id}

What this looks like in a runbook: Offboarding is where missed steps become security incidents. A Junto offboarding runbook doesn’t just delete the user — it executes the full sequence: disable the account, convert the mailbox to shared (or set forwarding per the client’s SOP), remove from all groups, revoke licenses, transfer OneDrive ownership, remove the device from NinjaOne, update the ConnectWise configuration, and document every action in ITGlue. The soft delete via Graph is one step in a chain that touches M365, the RMM, the PSA, and the documentation platform. Missing any step manually is how you end up with a terminated employee who still has VPN access.

Quick Reference: Old to New

Dead CommandNew PowerShellGraph API
New-MsolUser / New-AzureADUserNew-MgUserPOST /users
Get-MsolUser / Get-AzureADUserGet-MgUserGET /users/{id}
Set-MsolUser / Set-AzureADUserUpdate-MgUserPATCH /users/{id}
Remove-MsolUser / Remove-AzureADUserRemove-MgUserDELETE /users/{id}
Set-MsolUserPassword / Set-AzureADUserPasswordUpdate-MgUserPATCH /users/{id}
Get-AzureADGroupMember / Get-MsolGroupMemberGet-MgGroupMemberGET /groups/{id}/members
Add-AzureADGroupMemberNew-MgGroupMemberByRefPOST /groups/{id}/members/$ref
Remove-AzureADGroupMemberRemove-MgGroupMemberByRefDELETE /groups/{id}/members/{id}/$ref
Set-MsolUserLicense / Set-AzureADUserLicenseSet-MgUserLicensePOST /users/{id}/assignLicense
Get-MsolAccountSku / Get-AzureADSubscribedSkuGet-MgSubscribedSkuGET /subscribedSkus

The Bigger Problem: Scripts Don’t Scale Across Clients

Even after you migrate every script to Graph, you still have the same problem MSPs have always had with PowerShell automation: each client is different.

Client A uses E3 with conditional access. Client B uses Business Premium with a shared mailbox for every new hire. Client C has on-prem AD with Entra Connect and needs a delta sync after every change. Client D is cloud-only but requires a Temporary Access Pass instead of a password for first login.

You end up with a folder of scripts, each customized per client, each requiring someone to know which one to run and when. The Graph API is more powerful and more flexible than the old modules — but the fundamental challenge of running the right sequence of commands for the right client on the right ticket hasn’t changed.

This is what Junto’s agent tools and integrations are built for. The M365 integration handles every Graph API call in this article. CIPP extends that to multi-tenant management without per-tenant auth tokens. The NinjaOne integration handles RMM-side tasks like triggering AD sync or deploying agents. The ITGlue integration pulls the client’s SOP to determine which calls to make and in what order. The ConnectWise integration reads the ticket and documents the result. Pax8 reconciles licenses with billing. And the runbook engine chains them all together — so a tech approves one workflow instead of running ten commands across six tools.

The migration from AzureAD PowerShell to Graph is mandatory. The migration from client-specific scripts to AI-driven automation that uses Graph under the hood is what turns it into a competitive advantage.


Rewriting scripts for 50 clients sounds painful because it is. Book a demo with Junto — we’ll show you how to automate user management across your entire client base using the same Graph API calls above, without maintaining a single script. Already using CIPP? Even better — see how Junto integrates with your existing stack.

See Junto in action

15-minute demo. We'll show you AI triage working on your actual tickets.

Book a Demo