If your Exchange Online scripts still talk about remote PowerShell sessions and stored passwords, they are overdue for a refresh. In this post I'll walk through the current ExchangeOnlineManagement module: how to install and connect, how to set up certificate-based app-only authentication for unattended jobs, how the Get-EXO cmdlets and property sets keep bulk queries fast, and five short reports you can reuse straight away.
Prerequisites#
- PowerShell: the module is supported in PowerShell 7 on Windows, Linux and macOS, and in Windows PowerShell 5.1 on Windows. Module versions 3.10.0 and later need PowerShell 7.6 or later; versions 3.5.0 to 3.9.2 need 7.4 or later. Security & Compliance PowerShell (
Connect-IPPSSession) isn't available in PowerShell 7 on macOS or Linux. - Execution policy set to
RemoteSigned, and current (non-preview) PowerShellGet and PackageManagement modules on Windows. - Permissions: an Exchange role that covers what you want to read or change. For reporting, Global Reader or Exchange Recipient Administrator is usually enough.
Since October 2023 every connection is REST-based: no Basic authentication in WinRM, no remote PowerShell runspace, and the UseRPSSession switch is deprecated. The cmdlet names and parameters didn't change, but a few habits did. Invoke-Command doesn't work against the connection, Get-ConnectionInformation replaces Get-PSSession, and REST cmdlets time out after 15 minutes, which matters for very large bulk operations.
Step 1: Install or update the module#
# First install for the current user (no elevation needed)
Install-Module -Name ExchangeOnlineManagement -Scope CurrentUser
# Updates: use the same scope you installed with
Update-Module -Name ExchangeOnlineManagement -Scope CurrentUser
# Which version is installed, and where?
Get-InstalledModule ExchangeOnlineManagement | Format-List Name, Version, InstalledLocationStep 2: Connect interactively#
# Modern authentication, works with or without MFA
Connect-ExchangeOnline -UserPrincipalName admin@contoso.com -ShowBanner:$false
# No browser on this machine (Server Core, SSH session)? PowerShell 7 only.
Connect-ExchangeOnline -Device
# Import only the cmdlets a script needs: faster and a smaller memory footprint
Connect-ExchangeOnline -UserPrincipalName admin@contoso.com -CommandName Get-EXOMailbox,Get-EXOMailboxStatistics,Get-Mailbox
Get-ConnectionInformationTwo newer switches are worth knowing. Since version 3.7.0 the module no longer downloads cmdlet help by default; add -LoadCmdletHelp if you want Get-Help to work for Exchange cmdlets. And if sign-in fails with Web Account Manager errors on Windows, -DisableWAM (3.7.2 and later) falls back to the browser flow.
Step 3: App-only authentication for unattended scripts#
Scheduled reports shouldn't depend on a human account or a saved password. Certificate-based authentication (CBA) lets an app registration sign in with a certificate and inherit an Exchange role.
Create a certificate#
Microsoft notes that Cryptography Next Generation (CNG) certificates aren't supported for this scenario, so create a CSP key with -KeySpec KeyExchange:
$cert = New-SelfSignedCertificate -DnsName "exo-automation.contoso.com" `
-CertStoreLocation "Cert:\CurrentUser\My" -NotAfter (Get-Date).AddYears(1) -KeySpec KeyExchange
$cert | Export-Certificate -FilePath .\exo-automation.cer
$cert.ThumbprintRegister the app and grant the permission#
- In the Microsoft Entra admin center, go to App registrations › New registration, keep Accounts in this organizational directory only, and register it.
- Under API permissions › Add a permission › APIs my organization uses, choose Office 365 Exchange Online, then Application permissions › Exchange.ManageAsApp, and select Grant admin consent.
- Under Certificates & secrets › Upload certificate, upload the
.cerfile. - Assign a role. The simplest route is Microsoft Entra roles and administrators: open Exchange Administrator (or a narrower supported role such as Exchange Recipient Administrator or Global Reader) and add the app as an assignment. For least privilege, Microsoft also supports adding the app's service principal to a custom Exchange role group with
New-ServicePrincipalandAdd-RoleGroupMember.
Connect with the certificate#
Connect-ExchangeOnline -AppId "11111111-2222-3333-4444-555555555555" `
-CertificateThumbprint $cert.Thumbprint -Organization "contoso.onmicrosoft.com" -ShowBanner:$falseOrganization must be the tenant's primary .onmicrosoft.com domain. CertificateThumbprint is Windows-only; on other platforms, or when the certificate lives in Azure Key Vault, pass an X509Certificate2 object with -Certificate. In Azure Automation, Functions or VMs you can skip certificates entirely and use -ManagedIdentity -Organization contoso.onmicrosoft.com.
Note: App-only connections can't run the Microsoft 365 Group cmdlets New-UnifiedGroup, Remove-UnifiedGroup, Add-UnifiedGroupLinks and Remove-UnifiedGroupLinks. Use Microsoft Graph for those.
Step 4: Use the Get-EXO cmdlets and property sets#
The module ships nine Get-EXO* cmdlets optimised for bulk retrieval, including Get-EXOMailbox, Get-EXORecipient, Get-EXOCasMailbox, Get-EXOMailboxStatistics, Get-EXOMailboxPermission and Get-EXOMailboxFolderStatistics. Instead of returning well over 200 properties per mailbox like Get-Mailbox, they return a Minimum set by default (identity, display name, addresses, recipient type) and let you add what you need with -PropertySets (for example Delivery, Quota, Hold, Archive) or individual -Properties. Microsoft discourages -PropertySets All because it slows the call and reduces reliability. Filter on the server with -Filter (OPATH syntax) rather than piping everything to Where-Object, and set -ResultSize Unlimited when you really mean every mailbox.
Step 5: Five everyday reports#
1. Largest mailboxes#
Get-EXOMailbox -ResultSize Unlimited -RecipientTypeDetails UserMailbox |
ForEach-Object { Get-EXOMailboxStatistics -Identity $_.ExternalDirectoryObjectId } |
Select-Object DisplayName, ItemCount,
@{Name='SizeGB'; Expression={ [math]::Round(([int64]($_.TotalItemSize.ToString() -replace '^.*\(|\s*bytes\)$|,','')) / 1GB, 2) }} |
Sort-Object SizeGB -Descending | Select-Object -First 252. Last logon and last user activity#
Get-EXOMailbox -ResultSize Unlimited -RecipientTypeDetails UserMailbox | ForEach-Object {
$stats = Get-EXOMailboxStatistics -Identity $_.ExternalDirectoryObjectId -Properties LastLogonTime,LastUserActionTime
[pscustomobject]@{ User = $_.UserPrincipalName; LastLogon = $stats.LastLogonTime; LastUserAction = $stats.LastUserActionTime }
} | Sort-Object LastUserAction | Export-Csv .\mailbox-activity.csv -NoTypeInformationLastUserActionTime is the better signal: background processes can touch LastLogonTime on mailboxes nobody uses.
3. Mailboxes with forwarding configured#
Get-EXOMailbox -ResultSize Unlimited -PropertySets Delivery `
-Filter 'ForwardingSmtpAddress -ne $null -or ForwardingAddress -ne $null' |
Select-Object DisplayName, PrimarySmtpAddress, ForwardingSmtpAddress, ForwardingAddress, DeliverToMailboxAndForwardThis covers admin-configured forwarding only. User-created Inbox rules need Get-InboxRule per mailbox, or the Auto forwarded messages report in the Exchange admin center.
4. Shared mailboxes and who has Full Access#
Get-EXOMailbox -ResultSize Unlimited -RecipientTypeDetails SharedMailbox | ForEach-Object {
$shared = $_
Get-EXOMailboxPermission -Identity $shared.ExternalDirectoryObjectId |
Where-Object { $_.AccessRights -contains 'FullAccess' -and -not $_.IsInherited -and $_.User -ne 'NT AUTHORITY\SELF' } |
Select-Object @{Name='SharedMailbox'; Expression={ $shared.PrimarySmtpAddress }}, User
}5. Mailboxes with no user activity for 90 days, and compliance inactive mailboxes#
# Dormant but still active mailboxes (reuse the CSV from report 2)
Import-Csv .\mailbox-activity.csv |
Where-Object { -not $_.LastUserAction -or [datetime]$_.LastUserAction -lt (Get-Date).AddDays(-90) }
# "Inactive mailboxes" in the compliance sense: soft-deleted but preserved by a hold
Get-EXOMailbox -InactiveMailboxOnly -ResultSize Unlimited -PropertySets SoftDelete,Hold |
Select-Object DisplayName, PrimarySmtpAddress, WhenSoftDeleted, LitigationHoldEnabled, InPlaceHoldsVerify#
After connecting, Get-ConnectionInformation should list one connection with a valid token; for app-only, the AppId and Organization you passed appear in that object. Run each report with -ResultSize 10 first. If a cmdlet returns "isn't recognized", the role assigned to your account or app doesn't include it; if the result set is suspiciously small, you forgot -ResultSize Unlimited (the default is 1,000).
Tips & gotchas#
- Throttling: the fastest way to be throttled is
Get-Mailbox -ResultSize Unlimited | Where-Object. Use theGet-EXO*cmdlets, request only the properties you need, and filter server-side. If you see throttling or delay warnings, run fewer sessions in parallel and let the built-in retries work. - Memory leak: Microsoft warns that repeatedly connecting and disconnecting inside one script can leak memory. Connect once, limit the imported cmdlets with
-CommandName, disconnect at the end. - 15-minute timeout: split very large bulk changes (for example updating thousands of group members) into smaller batches.
- Multiple connections: the
Get-EXO*cmdlets always bind to the most recent Exchange Online connection in the window. - Clean up: finish scripts with
Disconnect-ExchangeOnline -Confirm:$false. It removes the temporary module files and the cached access token; with several connections open,-ConnectionIddisconnects just one.