Featured image of post PowerShell or Microsoft Graph? Choosing the Right Interface and Fixing Module Conflicts

PowerShell or Microsoft Graph? Choosing the Right Interface and Fixing Module Conflicts

When to use Exchange/PnP PowerShell vs. Microsoft Graph, and how to detect and fix assembly conflicts between Graph SDK, PnP and EXO modules.

“Should I do this with PowerShell or with Graph?” breaks down into two questions: which interface actually exposes what I need, and how will this thing run in six months when nobody is watching it. If you’ve ever mixed the Graph PowerShell SDK with PnP.PowerShell or the Exchange Online module in one script, you know the third question, the one nobody asks until it bites: will these modules even load together?

TL;DR

  • Use Exchange Online PowerShell for Exchange administration (mailbox permissions, transport rules, org config). Graph doesn’t cover most of it.
  • Use Microsoft Graph for identity, cross-service data and anything an application does: integrations, mail sending, reporting. For application access to mailboxes, Graph is the interface to build on.
  • The classic trap when combining them: assembly conflicts (Microsoft.Identity.Client, Microsoft.Graph.Core, Azure.Core …) between Microsoft.Graph, PnP.PowerShell, ExchangeOnlineManagement and Az. Connection order is a lottery; process isolation and fewer modules are the real fix.

What you’re actually choosing between

The Microsoft Graph PowerShell SDK is PowerShell, and it calls Graph under the hood. So the choice is between two kinds of interface:

  1. Service-specific admin modules: ExchangeOnlineManagement, MicrosoftTeams, PnP.PowerShell for SharePoint. They talk to the admin surfaces of a workload and expose things that simply don’t exist in Graph.
  2. Microsoft Graph: one REST API for Entra ID, mail, calendars, files, Teams, Planner and much more, callable from anything: the Graph PowerShell SDK, Invoke-RestMethod, C#, Python, Logic Apps or a ticketing system.

Which one to use depends first on which surface has the capability at all.

Where each one wins

TaskMy pickWhy
Full Access / Send As permissions, transport rules, org-wide Exchange settingsExchange Online PowerShellGraph has no endpoints for these. The new Exchange Online Admin API (preview) only covers a small subset like folder permissions and Send on Behalf.
Users, groups, licences, app registrations in Entra IDGraph (SDK or REST)This is Graph’s home turf.
An application sends or reads mail (ticketing, workflows)GraphsendMail / message endpoints with OAuth, scoped via RBAC for Applications.
Site provisioning, templates, SharePoint permissionsPnP.PowerShellFar richer than Graph’s SharePoint surface for admin tasks.
Teams policies (meeting, calling, messaging)MicrosoftTeams modulePolicy management lives in the Teams admin surface, not in Graph.
Cross-service reporting (users + licences + sign-ins + groups)GraphOne API, one auth model, paging and $select built in.

My rule of thumb: if a person runs it, use the module that’s closest to the workload. If an application runs it, use Graph – and give that application exactly the permissions it needs, nothing more.

A practical example: mail integrations

The LinkedIn post that inspired this article describes modernising a ServiceNow mail integration so that it sends and receives mail through Microsoft Graph. Here an application, not a person, works with mailboxes, and that’s where Graph fits: OAuth, granular permissions and a clean audit trail. Sending a message is a single call:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Microsoft Graph v1.0 – send mail as the service mailbox (app-only)
# Required application permission: Mail.Send (scoped via RBAC for Applications, see below)
$message = @{
    message = @{
        subject      = 'Ticket INC0012345 updated'
        body         = @{ contentType = 'Text'; content = 'Your ticket has been updated.' }
        toRecipients = @(@{ emailAddress = @{ address = 'adele.vance@contoso.com' } })
    }
    saveToSentItems = $true
}

Invoke-MgGraphRequest -Method POST `
    -Uri 'https://graph.microsoft.com/v1.0/users/servicedesk@contoso.com/sendMail' `
    -Body ($message | ConvertTo-Json -Depth 10) -ContentType 'application/json'

What I’d add from a regulated-environment perspective: an app with the application permission Mail.Send can, by default, send as any mailbox in the tenant. That’s not something you want to explain to an auditor. Use RBAC for Applications in Exchange Online to scope the service principal to the mailboxes it actually needs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Exchange Online PowerShell – scope a ticketing app to its service mailboxes only
# Prerequisite: the Entra app has NO tenant-wide Mail.Send consent (otherwise permissions add up)
# IDs from "Enterprise applications" in Entra, not from "App registrations"
New-ServicePrincipal -AppId '<app-id>' -ObjectId '<service-principal-object-id>' -DisplayName 'Ticketing'

New-ManagementScope -Name 'Ticketing-Mailboxes' `
    -RecipientRestrictionFilter "CustomAttribute10 -eq 'Ticketing'"

New-ManagementRoleAssignment -App '<service-principal-object-id>' `
    -Role 'Application Mail.Send' `
    -CustomResourceScope 'Ticketing-Mailboxes'

# Verify before you go live
Test-ServicePrincipalAuthorization -Identity '<service-principal-object-id>' -Resource 'servicedesk@contoso.com'

Note the irony: you configure Graph permissions with Exchange PowerShell. That’s exactly what the original post means by “use both”.

Module conflicts between Graph, PnP and EXO

Say you write a perfectly reasonable script: Graph for user data, PnP for SharePoint, EXO for mailboxes. It works on your machine. Then it fails in the next session with something like:

1
2
Could not load type 'Microsoft.Identity.Client.BaseAbstractApplicationBuilder`1'
from assembly 'Microsoft.Identity.Client, Version=4.50.0.0'

or

1
Method not found: '!0 Microsoft.Identity.Client.BaseAbstractApplicationBuilder`1.WithLogging(...)'

Why it happens

Every one of these modules is a binary module that ships its own copies of shared .NET libraries – most notably MSAL (Microsoft.Identity.Client.dll), but also Azure.Core, Azure.Identity, Microsoft.Graph.Core or System.Text.Json. PowerShell loads assemblies into one shared context per process, and only one version of an assembly with a given name can be loaded there. Whichever module loads it first wins; the other module then runs against a version it was never built for.

Microsoft’s own PowerShell documentation describes exactly this: the outcome depends on which module was loaded first, which code path ran, and whether the dependency is loaded at import or only on first use. That’s why the same script works, fails, and works again depending on what you did earlier in the session.

Known combinations that have caused trouble:

  • PnP.PowerShell + Microsoft.Graph: older PnP builds bundled an outdated Microsoft.Graph.Core (1.25.x) and older MSAL versions, which broke Connect-MgGraph and Invoke-MgGraphRequest once PnP was loaded.
  • ExchangeOnlineManagement + Microsoft.Graph: a long-running story. EXO 3.5.1 introduced a System.Text.Json clash; in June 2026 Tony Redmond reported that with Graph SDK 2.38 and EXO 3.10, neither connection order worked reliably in PowerShell 7: one order fails with the Method not found error above, the other with a NullReferenceException while acquiring a token.
  • Az.Accounts + Microsoft.Graph: same pattern with Azure.Core / Azure.Identity.

How to recognise it

First check: what is actually loaded in the current session?

1
2
3
4
5
6
7
8
# Which version of the usual suspects is loaded in this session – and from which module folder?
$suspects = 'Microsoft.Identity.Client', 'Microsoft.Graph.Core', 'Azure.Core', 'Azure.Identity', 'System.Text.Json'

[System.AppDomain]::CurrentDomain.GetAssemblies() |
    Where-Object { $_.GetName().Name -in $suspects } |
    Select-Object @{ n = 'Assembly'; e = { $_.GetName().Name } },
                  @{ n = 'Version';  e = { $_.GetName().Version } },
                  Location

If Location points into the PnP folder while you’re calling Connect-MgGraph, you’ve found your culprit.

Second check: which versions do your installed modules ship? This inventory makes conflicts visible before they hit production:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
$modules = 'Microsoft.Graph.Authentication', 'PnP.PowerShell', 'ExchangeOnlineManagement', 'Az.Accounts', 'MicrosoftTeams'
$dlls    = 'Microsoft.Identity.Client.dll', 'Microsoft.Graph.Core.dll', 'Azure.Core.dll', 'Azure.Identity.dll'

Get-Module -ListAvailable -Name $modules | ForEach-Object {
    $module = $_
    Get-ChildItem -Path $module.ModuleBase -Recurse -Include $dlls -File -ErrorAction SilentlyContinue |
        ForEach-Object {
            [pscustomobject]@{
                Assembly = $_.BaseName
                Version  = [System.Reflection.AssemblyName]::GetAssemblyName($_.FullName).Version
                Module   = "$($module.Name) $($module.Version)"
                Path     = $_.FullName
            }
        }
} | Sort-Object Assembly, Version | Format-Table Assembly, Version, Module -AutoSize

Some modules ship separate builds for .NET Framework and .NET, so you may see two entries per module. What matters is when the same assembly shows up with different versions across modules.

How to solve it

In order of preference:

1. Don’t load what you don’t need. If PnP.PowerShell is already connected, you often don’t need the Graph SDK at all. Invoke-PnPGraphMethod calls Graph with PnP’s connection (provided your PnP app registration has the required Graph permissions). And if you do need the SDK, install Microsoft.Graph.Authentication plus the one or two submodules you use (or just Invoke-MgGraphRequest) instead of the full Microsoft.Graph meta-module.

2. Isolate per process. Assemblies are per process, so separate processes can’t conflict. In PowerShell 7, Start-Job runs in a child process:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Graph work in its own process – app-only auth with a certificate
# Required application permission: User.Read.All
$auth = @{ ClientId = '<app-id>'; TenantId = 'contoso.onmicrosoft.com'; Thumbprint = '<cert-thumbprint>' }

$users = Start-Job -ArgumentList $auth -ScriptBlock {
    param($a)
    Connect-MgGraph -ClientId $a.ClientId -TenantId $a.TenantId -CertificateThumbprint $a.Thumbprint -NoWelcome
    Get-MgUser -Filter 'accountEnabled eq true' -All -Property id, userPrincipalName, department |
        Select-Object Id, UserPrincipalName, Department
} | Receive-Job -Wait -AutoRemoveJob

# Main session: PnP only – Graph's assemblies were never loaded here
Connect-PnPOnline -Url 'https://contoso.sharepoint.com/sites/hr' `
    -ClientId $auth.ClientId -Tenant $auth.TenantId -Thumbprint $auth.Thumbprint

The job returns deserialised objects (properties yes, methods no), which is exactly what you want for passing data between steps. Important: Start-ThreadJob and ForEach-Object -Parallel do not isolate. They run in the same process and share the loaded assemblies.

3. Split runbooks and pipelines. In Azure Automation, use separate runtime environments (PowerShell 7.4) per module set rather than one “everything” environment. In CI/CD, one job per workload. This also gives you cleaner permissions per identity, which helps with least privilege.

4. Pin versions deliberately. #Requires -Modules @{ ModuleName = 'ExchangeOnlineManagement'; RequiredVersion = '<tested-version>' } at the top of a script turns “it broke after an update” into “it refused to start”, which is a much better failure mode. Update modules on purpose, after a smoke test that imports and connects every module combination you use.

5. Last resort: connection order and DLL swapping. Connecting in a particular order, or copying a newer Microsoft.Identity.Client.dll into another module’s folder, is circulated widely in the community. It can get you through a day, but it’s unsupported, it’s overwritten by the next Update-Module, and, as the 2026 EXO/Graph case shows, it stops working without warning. I wouldn’t build production automation on it.

Other pitfalls

  • Windows PowerShell 5.1 and PowerShell 7 behave differently. Assembly loading differs between .NET Framework and .NET, so a combination that fails in 7 can work in 5.1 and vice versa. PnP.PowerShell 3.x requires PowerShell 7.4 or later anyway, so “just use 5.1” isn’t an option for SharePoint.
  • -DisableWAM is not a fix. Since EXO 3.7.0, Web Account Manager is the default broker for interactive sign-in. Disabling it with Connect-ExchangeOnline -DisableWAM sometimes sidesteps conflicts, but Microsoft explicitly describes it as a temporary measure.
  • App-only beats interactive for automation. Certificate-based auth avoids most WAM/broker issues and is what scheduled automation should use anyway.
  • Permissions add up. With RBAC for Applications, Entra consent and Exchange role assignments are additive. A tenant-wide Mail.Send consent in Entra ID silently defeats your scoped assignment.
  • The Exchange Online Admin API is preview and deliberately narrow. It complements EXO PowerShell; it doesn’t replace it.

Wrap-up

Admin modules give you the depth of each workload; Graph gives applications a single, auditable way in, which makes it the natural home for every mailbox integration. When you combine modules, treat them like components with dependencies: load less, isolate per process, pin versions and test updates before your runbooks do it for you.

References

Inspired by a LinkedIn post by Anthony Samuel.