To replace MSOnline user and licence scripts, install the Microsoft Graph PowerShell SDK, connect with Connect-MgGraph using only the read scopes you need, and use Get-MgUser, Get-MgSubscribedSku, Get-MgUserLicenseDetail and Get-MgAuditLogSignIn in place of the old Get-Msol* cmdlets. The main differences are that Graph returns only a small default set of user properties, so you request the rest with -Property, and that licences appear as SKU IDs you translate to names yourself. The recipes below cover the reports most MSOnline scripts produced, ready to export to CSV.
Who this is for and what you will have
This guide is for Microsoft 365 and Entra ID administrators with reporting scripts that broke when MSOnline was retired, or who are writing new tenant reports from scratch. You will end up with tested recipes for:
- A full user inventory with account status, type and creation date.
- Licence usage per SKU: purchased, assigned and remaining.
- A per-user licence report with readable SKU names.
- Unlicensed members and users holding a specific SKU.
- Last successful sign-in and inactive account reports.
- Recent failed sign-ins from the Entra sign-in logs.
- An unattended version that runs from a scheduled task with a certificate.
These reports are also the inventory you want before a tenant consolidation; see the Microsoft 365 cross-tenant migration architecture. Inactive account clean-up is a basic control in any zero trust access design.
What changed from MSOnline
Microsoft's migration FAQ is explicit: the Azure AD, Azure AD Preview and MSOnline PowerShell modules were deprecated on March 30, 2024, and MSOnline was retired as of May 30, 2025. The supported replacements are the Microsoft Graph PowerShell SDK and Microsoft Entra PowerShell, which is built on top of the Graph SDK.
Microsoft publishes a full cmdlet map. The entries most reporting scripts need are:
| MSOnline cmdlet | Microsoft Graph PowerShell cmdlet |
|---|---|
| Connect-MsolService | Connect-MgGraph |
| Get-MsolUser | Get-MgUser |
| Get-MsolAccountSku | Get-MgSubscribedSku |
| Set-MsolUserLicense | Set-MgUserLicense |
| Get-MsolGroup | Get-MgGroup |
| Get-MsolGroupMember | Get-MgGroupMember |
| Get-MsolRoleMember | Get-MgDirectoryRoleMember |
| Get-MsolCompanyInformation | Get-MgOrganization |
| Get-MsolDomain | Get-MgDomain |
| Get-MsolSubscription | Get-MgDirectorySubscription |
Three behaviours catch people out when they port scripts:
- Properties.
Get-MgUserreturns only a default set of properties: businessPhones, displayName, givenName, id, jobTitle, mail, mobilePhone, officeLocation, preferredLanguage, surname and userPrincipalName. Anything else, such as accountEnabled or assignedLicenses, must be requested with-Property. - Paging. The Graph API returns users in pages (100 by default, up to 999, or up to 500 when you include signInActivity). Use
-Allso the cmdlet follows every page for you. - Advanced queries. Some filters, such as counting assigned licences, need
-ConsistencyLevel eventualand-CountVariable.
Prerequisites
- PowerShell 7 or later is recommended. Windows PowerShell 5.1 also works with .NET Framework 4.7.2 or later and an execution policy of RemoteSigned or less restrictive.
- The Microsoft Graph PowerShell SDK:
Install-Module Microsoft.Graph -Scope CurrentUser -Repository PSGallery -Force
Get-InstalledModule Microsoft.GraphThe Microsoft.Graph module installs dozens of submodules. If you only need these reports, you can install just the submodules that contain the cmdlets used here: Microsoft.Graph.Users (Get-MgUser, Get-MgUserLicenseDetail), Microsoft.Graph.Identity.DirectoryManagement (Get-MgSubscribedSku) and Microsoft.Graph.Reports (Get-MgAuditLogSignIn). Microsoft.Graph.Authentication is installed with them by default. Microsoft recommends the v1.0 module for scripts; the beta module (Microsoft.Graph.Beta) can change without notice.
- Permissions for the reports you run:
| Report | Graph permission | Other requirement |
|---|---|---|
| User inventory, licence details | User.Read.All | None |
| Tenant SKUs and counts | Organization.Read.All | None |
| signInActivity (last sign-in) | AuditLog.Read.All plus User.Read.All | Microsoft Entra ID P1 or P2 |
| Sign-in logs | AuditLog.Read.All | Admin role such as Reports Reader, Global Reader or Security Reader for delegated access |
Connect with the read-only scopes for the session and confirm what you were granted:
Connect-MgGraph -Scopes "User.Read.All","Organization.Read.All","AuditLog.Read.All"
Get-MgContext | Select -ExpandProperty ScopesThe sign-in persists across sessions because the token is cached for the current user. Run Disconnect-MgGraph when you are finished, or connect with -ContextScope Process to keep the sign-in to the current PowerShell session only.
Recipe 1: User inventory
$props = "Id","DisplayName","UserPrincipalName","Mail","AccountEnabled",
"UserType","Department","JobTitle","CreatedDateTime"
Get-MgUser -All -Property $props |
Select-Object $props |
Export-Csv -Path .\users.csv -NoTypeInformation -Encoding UTF8The same $props array is used for -Property and Select-Object, so the CSV columns always match what you asked Graph for. Add or remove properties from the array to suit the report.
To reproduce the old Get-MsolUser -All | Where-Object { $_.UserType -eq 'Guest' } pattern, filter on the server instead:
Get-MgUser -All -Filter "userType eq 'Guest'" -ConsistencyLevel eventual -CountVariable guestCount -Property $props |
Select-Object $propsRecipe 2: Licence usage per SKU
This is the replacement for Get-MsolAccountSku. SkuPartNumber is the plan's internal name (for example, ENTERPRISEPACK for Office 365 E3), PrepaidUnits.Enabled is the number purchased, and ConsumedUnits is the number assigned.
Get-MgSubscribedSku -All |
Select-Object SkuPartNumber, SkuId,
@{n='Purchased';e={$_.PrepaidUnits.Enabled}},
@{n='Assigned'; e={$_.ConsumedUnits}},
@{n='Available';e={$_.PrepaidUnits.Enabled - $_.ConsumedUnits}} |
Sort-Object SkuPartNumber |
Format-Table -AutoSizeTo list the service plans inside a SKU, read its ServicePlans property:
(Get-MgSubscribedSku -All | Where-Object SkuPartNumber -eq 'ENTERPRISEPACK').ServicePlansMicrosoft's "Product names and service plan identifiers for licensing" reference maps every SKU part number to a friendly product name if you need one in the report.
Recipe 3: Per-user licence report with SKU names
Users carry licences as AssignedLicenses, a list of SKU IDs. Build a lookup from the tenant's SKUs once, then translate each user's IDs:
$skuNames = @{}
Get-MgSubscribedSku -All | ForEach-Object { $skuNames[$_.SkuId] = $_.SkuPartNumber }
Get-MgUser -All -Property "DisplayName","UserPrincipalName","AccountEnabled","AssignedLicenses" |
Where-Object { $_.AssignedLicenses.Count -gt 0 } |
ForEach-Object {
[pscustomobject]@{
DisplayName = $_.DisplayName
UserPrincipalName = $_.UserPrincipalName
AccountEnabled = $_.AccountEnabled
Licenses = ($_.AssignedLicenses | ForEach-Object { $skuNames[$_.SkuId] }) -join ';'
}
} |
Export-Csv -Path .\user-licenses.csv -NoTypeInformation -Encoding UTF8Sorting this file by AccountEnabled shows licences still assigned to disabled accounts, which is usually the quickest licence saving in a tenant. For one user, Get-MgUserLicenseDetail -UserId alex@contoso.com returns the SKUs and, with -Property ServicePlans, the individual services and their status.
Recipe 4: Unlicensed users and users with a given SKU
The first filter counts assigned licences on the server (assignedLicenses/$count), which is an advanced query, so -ConsistencyLevel eventual and -CountVariable are required. Both examples follow Microsoft's documentation, which uses the same parameters for the SKU filter as well.
Unlicensed members, excluding guests:
Get-MgUser -Filter "assignedLicenses/`$count eq 0 and userType eq 'Member'" `
-ConsistencyLevel eventual -CountVariable unlicensedCount -All `
-Property "DisplayName","UserPrincipalName","AccountEnabled","CreatedDateTime" |
Select-Object DisplayName, UserPrincipalName, AccountEnabled, CreatedDateTime
Write-Host "Found $unlicensedCount unlicensed member accounts."The backtick before $count stops PowerShell from expanding it as a variable inside double quotes. With single quotes you don't need it, as in 'assignedLicenses/$count eq 0'.
Users with a specific SKU, here Microsoft 365 E5 (SPE_E5):
$e5 = Get-MgSubscribedSku -All | Where-Object SkuPartNumber -eq 'SPE_E5'
Get-MgUser -Filter "assignedLicenses/any(x:x/skuId eq $($e5.SkuId))" `
-ConsistencyLevel eventual -CountVariable e5Count -All |
Select-Object DisplayName, UserPrincipalName
Write-Host "Found $e5Count users with SPE_E5."Recipe 5: Last sign-in and inactive accounts
Every user object has a signInActivity property that Microsoft Entra ID keeps for as long as the user exists. It isn't returned unless you ask for it, and it requires Entra ID P1 or P2 and AuditLog.Read.All. It has three timestamps:
| Property | Meaning |
|---|---|
| LastSignInDateTime | Last interactive sign-in attempt, successful or not |
| LastNonInteractiveSignInDateTime | Last non-interactive attempt, for example a client refreshing tokens |
| LastSuccessfulSignInDateTime | Last successful interactive or non-interactive sign-in |
For inactivity, use LastSuccessfulSignInDateTime. Failed attempts update LastSignInDateTime, so an account under a password-spray attack can look active when nobody has used it. The successful timestamp has been recorded since December 1, 2023 and wasn't backfilled.
Full last-sign-in report:
$report = Get-MgUser -All -Property "DisplayName","UserPrincipalName","AccountEnabled","UserType","CreatedDateTime","SignInActivity" |
Select-Object DisplayName, UserPrincipalName, AccountEnabled, UserType, CreatedDateTime,
@{n='LastSuccessfulSignIn';e={$_.SignInActivity.LastSuccessfulSignInDateTime}},
@{n='LastInteractiveAttempt';e={$_.SignInActivity.LastSignInDateTime}},
@{n='LastNonInteractiveAttempt';e={$_.SignInActivity.LastNonInteractiveSignInDateTime}}
$report | Export-Csv -Path .\last-sign-in.csv -NoTypeInformation -Encoding UTF8Inactive enabled members, using a 90-day window (Microsoft notes that in many organizations a reasonable window for inactive accounts is between 90 and 180 days):
$cutoff = (Get-Date).ToUniversalTime().AddDays(-90)
$report |
Where-Object {
$_.AccountEnabled -and $_.UserType -eq 'Member' -and
( -not $_.LastSuccessfulSignIn -or $_.LastSuccessfulSignIn -lt $cutoff )
} |
Sort-Object LastSuccessfulSignIn |
Export-Csv -Path .\inactive-members.csv -NoTypeInformation -Encoding UTF8Accounts with an empty LastSuccessfulSignIn have no recorded successful sign-in since the property was introduced. Check CreatedDateTime before acting on them, because new accounts that nobody has used yet also appear here.
You can also filter on the server, which is faster in large tenants. signInActivity supports eq, ne, not, ge and le, but not in combination with any other filterable property, so apply the account and user type checks afterwards:
$cutoffText = (Get-Date).ToUniversalTime().AddDays(-90).ToString("yyyy-MM-ddTHH:mm:ssZ")
Get-MgUser -All -Filter "signInActivity/lastSuccessfulSignInDateTime le $cutoffText" `
-Property "DisplayName","UserPrincipalName","AccountEnabled","UserType","SignInActivity" |
Where-Object { $_.AccountEnabled -and $_.UserType -eq 'Member' }Two limits to keep in mind: the last sign-in values can take up to 24 hours to update, and the property isn't a substitute for the sign-in logs when you investigate a specific event.
Recipe 6: Recent failed sign-ins
Get-MgAuditLogSignIn reads the Entra sign-in logs, newest first, for events within the default retention period. Always filter by a time range; Microsoft recommends it to avoid request timeouts.
$since = (Get-Date).ToUniversalTime().AddDays(-1).ToString("yyyy-MM-ddTHH:mm:ssZ")
Get-MgAuditLogSignIn -Filter "createdDateTime ge $since" -All |
Where-Object { $_.Status.ErrorCode -ne 0 } |
Select-Object CreatedDateTime, UserPrincipalName, AppDisplayName, IpAddress,
ClientAppUsed, @{n='ErrorCode';e={$_.Status.ErrorCode}},
@{n='FailureReason';e={$_.Status.FailureReason}} |
Export-Csv -Path .\failed-sign-ins.csv -NoTypeInformation -Encoding UTF8An error code of 0 means success. The log API covers interactive sign-ins and successful federated sign-ins. Applied Conditional Access policy details are only included if the caller can read Conditional Access data.
Run the reports unattended
Interactive sign-in doesn't suit a scheduled task. Register an app in the Microsoft Entra admin center, grant it the application permissions User.Read.All, Organization.Read.All and AuditLog.Read.All with admin consent, upload a certificate, and connect with the certificate thumbprint:
Connect-MgGraph -ClientId "00001111-aaaa-2222-bbbb-3333cccc4444" `
-TenantId "aaaabbbb-0000-cccc-1111-dddd2222eeee" `
-CertificateThumbprint "THUMBPRINT_OF_CERT_IN_CURRENTUSER_MY"The certificate must be in Cert:\CurrentUser\My or Cert:\LocalMachine\My on the machine running the task. When the script runs on an Azure resource that has a managed identity, Connect-MgGraph -Identity uses that identity instead, so there is no certificate or secret to rotate.
Troubleshooting
"Authorization_RequestDenied. Insufficient privileges to complete the operation." The session or app lacks a required permission, or the signed-in user lacks a required Entra role. Run Get-MgContext to see the granted scopes and auth type. For app-only connections, confirm the permissions were added as application permissions (not delegated) and that admin consent was granted. To see which permissions a cmdlet can use, run Find-MgGraphCommand -Command Get-MgUser | Select -First 1 -ExpandProperty Permissions. After changing consent, disconnect and connect again to get a new token.
Columns in the CSV are empty. The property wasn't requested. Add it to -Property; Graph only returns the default set otherwise.
Only 100 users come back. You left out -All, so the cmdlet returned the first page only.
A count filter fails with an unsupported query error. Filters on assignedLicenses/$count are advanced queries. Add -ConsistencyLevel eventual and -CountVariable.
SignInActivity is blank, or the request is denied. Reading signInActivity requires a Microsoft Entra ID P1 or P2 licence and AuditLog.Read.All, so check both first. If it is blank only for some users, those accounts have never attempted a sign-in, or last did so before April 2020.
A combined filter on signInActivity fails. signInActivity can't be combined with other filterable properties. Filter on sign-in activity alone and apply the rest with Where-Object.
Sign-in log queries time out. Narrow the createdDateTime range and run several smaller queries.
Scripts break after a module update. Pin a module version in scheduled jobs and test updates first, and keep production scripts on the v1.0 cmdlets rather than beta.
Checklist
- MSOnline references removed from every scheduled script; Graph SDK installed in the PowerShell version that runs them.
- Read-only scopes only: User.Read.All, Organization.Read.All, AuditLog.Read.All.
- Every
Get-MgUsercall uses-Alland an explicit-Propertylist. - Licence reports translate SKU IDs through
Get-MgSubscribedSku. - Advanced queries use
-ConsistencyLevel eventualwith-CountVariable. - Inactivity based on
LastSuccessfulSignInDateTime, with new and never-used accounts checked separately. - Sign-in log queries always bounded by
createdDateTime. - Unattended runs use a certificate or managed identity with application permissions and admin consent.
References
- Azure AD PowerShell to Microsoft Graph and Microsoft Entra PowerShell migration FAQ
- Find Azure AD and MSOnline cmdlets in Microsoft Graph PowerShell
- Install the Microsoft Graph PowerShell SDK
- Use Microsoft Graph PowerShell authentication commands
- Use Find-MgGraphCommand
- List users (Microsoft Graph v1.0)
- signInActivity resource type
- List signIns (Microsoft Graph v1.0)
- Get-MgAuditLogSignIn
- How to manage inactive user accounts
- View Microsoft 365 licenses and services with PowerShell
- View Microsoft 365 account license and service details with PowerShell
- View licensed and unlicensed Microsoft 365 users with PowerShell
- Troubleshoot Authorization_RequestDenied error with Microsoft Graph