Exchange Online PowerShell module gets rid of the WinRM dependence

Exchange Online Remote PowerShell has been around for a long time. It is largely thanks to it that Exchange Online offers the best admin experience out of all Microsoft 365 workloads in terms of usability, breadth of operation coverage, automation capabilities, RBAC controls, auditing, and so on. One can even argue that Remote PowerShell is one of the building blocks that made the cloud service possible in the first place.

That said, the cloud did expose some of the weaknesses of Remote PowerShell, in the form or reliability and performance issues. Especially in large tenants, long running scripts are known to pose a challenge, and throttling controls imposed by Microsoft didn’t help either. As software inevitably evolves, the reliance on old components brought other problems. One such example is the dependance on WinRM, which forced Microsoft to use the basic authentication endpoint as a workaround to facilitate OAuth/OIDC implementation.

With the release of the V2 Exchange Online PowerShell module, Microsoft addressed some of these shortcomings. In addition, the set of new REST-based cmdlets helped alleviate some of the stress when running operations against a large number of objects. And now, as an update to the V2 module, Microsoft is tackling the dependence on WinRM, promising to bring a slew of performance and reliability improvements in the process.

Improvements to PowerShell cmdlet execution (getting rid of WinRM)

In order to bypass the WinRM layer, the Exchange Online V2 module (version 2.0.6 or later) by default no longer establishes a remote PowerShell session. It sounds crazy I know, but you can easily verify this. First, connect to the service via Connect-ExchangeOnline cmdlet, then run the Get-PSSession cmdlet. You should see no PSSession established, yet Exchange Online cmdlets seem to run just fine, how come? In short, Exchange Online PowerShell cmdlets have been reworked to “proxy” their payload over HTTPS, effectively using the Invoke-WebRequest cmdlet as a wrapper!

So how does it work? Just like with a “traditional” Remote PowerShell session, all the Exchange Online cmdlets you have access to as per the RBAC model will be downloaded to a temporary file and loaded as a new module. To distinguish from the “old” type of module, the name has been changed to (whereas the old ones looked like The most revealing piece of information is the cmdlet definition itself. For example, let’s take a look at the definition of Get-AcceptedDomain:

PowerShell cmdlet

To find out what exactly this Execute-Command does, one can peek into the temp module file containing its definition. Long story short, using the cmdlet results in sending a POST request against a RESTful endpoint, with a JSON payload and an OAuth token. If all of this sounds familiar, you’re not wrong – the process is very similar to what the set of REST-based “V2” cmdlets do. And if you are too lazy to look up the cmdlet definition, using the –Verbose switch will also give you a clue:

PowerShell cmdletIn effect, Microsoft has “rewritten” the Exchange Online cmdlets and is now proxying them over an HTTPS REST-based endpoint, getting rid of the Remote PowerShell session and its dependencies, and reaping some benefits in the process. This solution inherently supports Modern authentication – whereas previously an OAuth token was passed over the WinRM basic auth endpoint, now all communication is done in a Graph API-like manner. This in turn brings many of the improvements we’ve already seen as part of the REST-based cmdlets, such as increased reliability thanks to the stateless nature of the protocol. Performance is also greatly improved, both in terms of establishing the initial connection and subsequent cmdlet executions, with up to 50% boosts observed.

Probably the biggest benefit of all is the fact that the reworked cmdlets are at functional parity with the “old” ones, meaning that you will be able to reuse your scripts and modules with minimal or no changes. Well, apart from some output formatting annoyances. That said, not every cmdlet has been updated to take advantage of this new method, and at the time of writing this article 421 out of 800+ Exchange Online cmdlets are available:

PowerShell cmdlets comparison between the old WinRM-based module and the new REST-based one

Keep in mind that the numbers above represent the cmdlets available to the current user, as per his role(s) within Exchange’s RBAC model. Microsoft continues to add (or occasionally remove when a fix is needed) cmdlets, and by the time the 2.0.6 version of the Exchange Online PowerShell module gets released in GA, we can expect all but the least used cmdlets to be part of it. Should you need to use any of the “missing” cmdlets or switch to the older version for some reason, you can do so by utilizing the –UseRPSSession switch:

Connect-ExchangeOnline -UserPrincipalName -UseRPSSession

And while we’re on the topic of new parameters, you might have noticed that few cmdlets now feature the -UseCustomRouting one, which takes a mailbox identifier as value. The idea is that using this parameter should help route requests to the most appropriate mailbox server, which should in turn increase performance in some scenarios. This parameter is not available when using the old Remote PowerShell session connectivity method. For the time being, a total of 12 cmdlets take advantage of the -UseCustomRouting parameter:

PowerShell cmdletsAs mentioned already, the “new” cmdlets are backwards compatible for the most part and the experience when using them should remain the same. There are some minor annoyances with the output and “shortcut” notations I’ve become accustomed to, but important things such as pipeline support are taken care of. One important addition is the automatic use of batches, as hinted by the cmdlet definition screenshot above. Batching works by combining several cmdlet executions into a single request and is available out of the box for many cmdlets. For example, if you take the output of Get-Mailbox and pass it to the Get-CASMailbox cmdlet, the execution happens in batches of 10 objects, which brings noticeable performance increase compared to cmdlets that do not support batching.

Lastly, it’s worth mentioning that none of the changes detailed above affect any of the REST-based cmdlets. Those are still available in any version of the V2 module and any connectivity mode.

Executing Exchange Online cmdlets outside of PowerShell

Now that we have some idea on how the new Exchange Online cmdlets work, let’s get to the best part – bypassing them completely! Much like we did back when the original REST-based cmdlets were first released, we can obtain an access token and pass it along with a web request against the REST endpoint. All the details we need were revealed from the quick exploration we did in the previous section, so let’s get started.

First, we will need an access token. To obtain one, we can either use the built-in “Microsoft Exchange REST API Based PowerShell” application, with appID of fb78d390-0c51-40cd-8e17-fdbfab77341b, or use our own app, with at least Exchange.ManageAsApp permissions. Here’s an example of how to obtain an access token for the current user by leveraging the MSAL binaries:

Add-Type -Path "C:\Program Files\WindowsPowerShell\Modules\MSAL\Microsoft.Identity.Client.dll" #Load the MSAL binaries
$app =  [Microsoft.Identity.Client.PublicClientApplicationBuilder]::Create("fb78d390-0c51-40cd-8e17-fdbfab77341b").WithRedirectUri("").WithBroker().Build()
$Scopes = New-Object System.Collections.Generic.List[string]
$Scope = ""
$token = $app.AcquireTokenInteractive($Scopes).ExecuteAsync().Result

Other methods will also work, including methods leveraging other flows. If you plan to use the client credentials flow, you must use your own app registration. Once a valid token is obtained, we have to prepare the web request body and headers. Apart from providing the token as part of the Authorization header, few additional headers can be added. The X-ResponseFormat header can be used to specify the format in which we want to obtain any output, with available values of JSON or CliXML. The latter is what’s used by default for any of the “reworked” cmdlets. For most other purposes, you will likely want a JSON-formatted output.

It’s recommended to also include the X-AnchorMailbox header, which serves as hint to properly route the request. The value you need to specify for it is in the format. The Accept-Language header can be used to specify the locale, though it doesn’t seem to make much difference. The Accept-Encoding header is also supported, with a gzip value. Here’s how a sample set of headers should look like:

$authHeader = @{
     'Authorization'="Bearer $($token.AccessToken)"
     'X-ResponseFormat'= "json" 
     'X-AnchorMailbox' = ""

Now, we need to construct the request body, which represents the cmdlet we want to execute and any relevant parameters, all packaged in JSON formatted string. The CmdletName element specifies the name of the cmdlet we want to run, and the Parameters array lists each individual parameter and their corresponding values. It’s all then packaged in a CmdletInput element and looks something like this:

$body = @{
     CmdletInput = @{

Two more pieces of information are needed. First, the endpoint URL, which as we saw in the previous section is, where the GUID represents your tenant identifier (you can also use Lastly, we need to know the type of request, which is POST. Putting it all together, we can now run our simple Get-Mailbox cmdlet without having to load the Exchange Online PowerShell module, or PowerShell itself.

$uri = ""
$res = Invoke-WebRequest -Method POST -Uri $uri -Headers $authHeader -Body ($body | ConvertTo-Json -Depth 5) -Verbose -Debug -ContentType 'application/json'
($res.Content | ConvertFrom-Json).Value

The JSON-formatted output we requested is not very suitable for display purposes, so we can transform it a bit. The end result will be a PSCustomObject, containing all the mailbox properties as displayed below. One thing to keep in mind is that the format data entries will be visible in the output for anything but string properties, so some additional handling might be needed:

REST example via PowerShellAnd that’s all it there is to it. We’re now effectively running Exchange Online PowerShell cmdlets against an HTTPS endpoint, in a manner similar to the Graph API. Which is likely what many ISVs will end up doing, until Microsoft provides proper Graph API endpoints for Exchange Online management. Well, assuming the method outlined above is considered a “supported” one, that is. For the time being, this is all in preview, so no official support (as also hinted by the /beta endpoint notation). Things might change once the 2.0.6 version of the module GA’s.

In addition, you can also batch multiple requests together, then send them as a single JSON payload over the{tenantid}/$batch endpoint. But that’s a topic for another article 🙂

UPDATE 21/05/2022: The next preview version of the module, namely 2.0.6-Preview6 was just released. Among other things, it brings the number of cmdlets to 799 (again, subject to your permissions). Which in turn means GA should be just around the corner now 🙂

20 thoughts on “Exchange Online PowerShell module gets rid of the WinRM dependence

  1. Egor says:

    One thing I’m still struggling to understand is how to pack hashtables when adding or removing multivalue parameters. For example, what is the REST equivalent of Set-Mailbox -EmailAddresses @{add=””} I tried sending EmailAddresses as both hashtable and string, it fails.

    1. Vasil Michev says:

      You can always use Fiddler to see the actual call being made. In this case:

      $body = @{
          CmdletInput = @{

      The “@odata.type”=”#Exchange.GenericHashTable” element is mandatory it seems, without it parsing the JSON fails.

      Oh, and don’t forget to set the depth when using Convertto-Json.

      1. Egor says:

        Yes, sorry I realized this (with the help of Mr. Fiddler) after I posted, but couldn’t edit my comment as it was pending moderation. The correct syntax in this case is:


  2. Egor says:

    One thing to note — if you’re submitting a command with parameter that has special characters, for example:


    (Note the paragraph character) You will end up with a server exception similar to:

    “Unable to translate bytes [A7] at index 136 from specified code page to Unicode.”

    The fix here is to supply content-type to Invoke-RestMethod:
    -ContentType “application/json;charset=utf-8”

    This has to be specified explicitly, packing this Content-type header to -Headers (as is the case in the example above) won’t help. Evidently, ContentType parameter is interpreted by Invoke-RestMethod (as opposed to just sent in the request header) and Powershell performs the required encoding translation.

  3. Branko says:

    Is it true statement to say that when App-only authentication for unattended scripts in Exchange Online PowerShell is used that RBAC model is broken?
    In other words you cannot use any Exchange custom RBAC role groups with custom scopes. App-only authentication will always use builtin Exchange Admin role.

    I’m wondering how to have unattended scripts with unique role each now when basic auth is no more available.

  4. Michael S says:

    Hi Vasil,

    This article has been an extremely valuable resource.

    I would like to share some of my findings to add to this.

    If you specify a static GUID for the ‘connection-id’ header it maintains the “Session” to the specific Exchange Server. So you can execute multiple commands without worrying about replication delays, etc.

    The “X-Anchor” header can be “UPN:SystemMailbox{bb558c35-97f1-4cb9-8ff7-d53741dc928c}”
    Of course replace with your relevant tenant domain name.
    If your ‘X-Anchor’ Header is invalid, pagination will not work.

    Setting the ‘Prefer’ header to ‘odata.maxpagesize=1000’ will return the maximum amount of objects. Without this, it only returns 100 items per page.

    Similar to MS Graph API, you can specify a “$select” filter to return only required values. This reduces the data being sent back from the Query and speeds up the command execution a little bit.

    Here’s a little function I’ve put together in PoSh that incorporates these items.

    function Invoke-ExchangeRestCommand([String]$command, [HashTable]$cargs, [string]$properties, [string]$exo_token, [string]$tenant_name){

    $body = @{
    CmdletInput = @{

    if($cargs -ne $null){
    $body.CmdletInput += @{Parameters= [HashTable]$cargs}
    $json = $body | ConvertTo-Json -Depth 5 -Compress
    Write-Host $json

    [string]$url = $(“$tenant_name/InvokeCommand”)
    $url = $url + $(‘?%24select=’+$($Properties.Trim().Replace(‘ ‘,”)))
    [Array]$Data = @()

    $headers = New-Object “System.Collections.Generic.Dictionary[[String],[String]]”
    $headers.Add(“prefer”, “odata.maxpagesize=1000”)
    $headers.Add(“x-anchormailbox”, $(“UPN:SystemMailbox{bb558c35-97f1-4cb9-8ff7-d53741dc928c}@$tenant_name”))
    $headers.Add(“x-responseformat”, “json”)
    $headers.Add(“content-type”, “application/json”)
    $headers.Add(“connection-id”, $conn_id)
    $headers.Add(“accept”, “application/json”)
    $headers.Add(“accept-language”, “en-GB”)
    $headers.Add(“accept-charset”, “UTF-8”)
    $headers.Add(“authorization”, “Bearer $exo_token”)
    $headers.Add(“warningaction”, “”)
    $headers.Add(“accept-encoding”, “gzip”)
    $headers.Add(“x-serializationlevel”, “Partial”)
    $headers.Add(“x-clientmoduleversion”, “2.0.6-Preview6”)
    $headers.Add(“user-agent”, “Mozilla/5.0 (Windows NT; Windows NT 10.0; en-AU) WindowsPowerShell/5.1.19041.1682”)
    $headers.Add(“host”, “”)

    $result = Invoke-RestMethod $url -Method ‘POST’ -Headers $headers -Body $json -TimeoutSec $defaultTimeout -UseBasicParsing

    if(@($result.value).Count -ne 0){
    $Data += $($result.value)
    Write-Host “Got $($result.value.Count) items”
    try{$url = $result.’@odata.nextLink’}catch{$url = ”}
    Write-Host “Getting next page…”
    return @($Data)

    ##Unique ‘connection-id’ to maintain “Session”
    $conn_id = $([guid]::NewGuid().Guid).ToString()

    [int]$defaultTimeout = 30

    Invoke-ExchangeRestCommand `
    -command ‘Get-Mailbox’ `
    -cargs @{RecipientTypeDetails = @(‘UserMailbox’); ResultSize = “Unlimited”;} `
    -properties ‘DisplayName,UserPrincipalName,RecipientTypeDetails’ `
    -tenant_name ‘’ `
    -exo_token “EXO_JWT_Token”

    Once again, Thank you to you Vasil.

    1. Vasil Michev says:

      Thanks for sharing these additional bits, very useful. I’m almost certain I tested $select and didn’t get the expected result, good to know this actually works.

  5. VoidMain says:

    I have both a PowerShell and C# multi-tenant version of the code working, thank you very much Vasil for the blog post and pointers.

    All the old commands work fine, but NONE of the the *-EXO* commands work. Has anyone else gotten the EXO versions of the commands to work, specifcially via the REST API


  6. VoidMain says:

    Great article Vasil, thank you!

    I am trying to call the REST API from C# for testing, I have been able to get a valid (I think) token by using the following code:
    string scope = “”;
    IConfidentialClientApplication app = ConfidentialClientApplicationBuilder.Create(clientId)

    var TokenResult = app.AcquireTokenForClient(new[] { scope }).ExecuteAsync().Result;
    string accessToken = TokenResult.AccessToken;

    When I paste the accessToken into, I have
    “aud”: “”,
    “appid”: “<>”,
    “roles”: [
    “wids”: [

    Which all looks OK to me and works in PowerShell.

    The last bit I need to get is properly formatted JSON for the body, it is not 100% clear to me how to translate the example you provided:
    $body = @{
    CmdletInput = @{

    Any help would be appreciated.


    1. VoidMain says:

      Of course as soon as I asked the question I found the answer 🙂

      “CmdletInput”: {
      “Parameters”: {
      “Identity”: “”
      “CmdletName”: “Get-Mailbox”

      I also created a few simple classes to represent the JSON in C#
      public class EXO
      public CmdletInput CmdletInput { get; set; }
      public class CmdletInput
      public Parameters Parameters { get; set; }
      public string CmdletName { get; set; }

      public class Parameters
      public string Identity { get; set; }

      Feel free to delete my question, or leave it for others that may be looking for the info.

      Thanks again for your blogs and video, it has helped me immensely.

  7. Yannik says:

    Hi Visal,

    as you described, the exo v2 module doesn’t use PSSessions anymore.

    Therefore the good old
    $Authorization = “Bearer {0}” -f $authResult.Result.AccessToken
    $Password = ConvertTo-SecureString -AsPlainText $Authorization -Force
    $Ctoken = New-Object System.Management.Automation.PSCredential -ArgumentList “”, $Password
    $Session = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri -Credential $Ctoken -Authentication Basic -AllowRedirection
    Import-PSSession $Session

    can not be used anymore in the future. (Which I’m glad about, since the concurrency limit was horrible)

    Unfortunately, Connect-ExchangeOnline doesn’t have a -AccessToken parameter like Connect-MgGraph or Connect-MsolService to use existing AccessTokens.

    Do you know a way to solve this?

    1. Vasil Michev says:

      The second part of the article deals with how to “connect” to the new endpoint by providing a token directly, you don’t need to use Connect-ExchangeOnline or even load the module. If you want to establish a good old Remote PS Session instead, the method you outlined above still works.

    2. Egor says:

      > doesn’t have a -AccessToken parameter

      It has now! Download the latest v3 preview!

  8. Stuart says:

    Great blog post as always, Vasil. By the way, the number of cmdlets has increased already under 2.0.6-Preview5. I am still on Preview5 and I now see 800 cmdlets. Microsoft have informed me that they can release new REST-based versions of the cmdlets dynamically in the backend of Exchange – it doesn’t require an update to the EXO PS module.

    1. Vasil Michev says:

      Yup, I probably didn’t word that one correctly 🙂


Leave a Reply

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

This site uses Akismet to reduce spam. Learn how your comment data is processed.