Microsoft 365

Microsoft Graph PowerShell recipes for user, licence and sign-in reports

Replace retired MSOnline scripts with Microsoft Graph PowerShell recipes for user inventories, licence counts, unlicensed and inactive users, and recent failed sign-ins.

11 min read
On this page

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 cmdletMicrosoft Graph PowerShell cmdlet
Connect-MsolServiceConnect-MgGraph
Get-MsolUserGet-MgUser
Get-MsolAccountSkuGet-MgSubscribedSku
Set-MsolUserLicenseSet-MgUserLicense
Get-MsolGroupGet-MgGroup
Get-MsolGroupMemberGet-MgGroupMember
Get-MsolRoleMemberGet-MgDirectoryRoleMember
Get-MsolCompanyInformationGet-MgOrganization
Get-MsolDomainGet-MgDomain
Get-MsolSubscriptionGet-MgDirectorySubscription

Three behaviours catch people out when they port scripts:

  1. Properties. Get-MgUser returns 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.
  2. Paging. The Graph API returns users in pages (100 by default, up to 999, or up to 500 when you include signInActivity). Use -All so the cmdlet follows every page for you.
  3. Advanced queries. Some filters, such as counting assigned licences, need -ConsistencyLevel eventual and -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.Graph

The 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:
ReportGraph permissionOther requirement
User inventory, licence detailsUser.Read.AllNone
Tenant SKUs and countsOrganization.Read.AllNone
signInActivity (last sign-in)AuditLog.Read.All plus User.Read.AllMicrosoft Entra ID P1 or P2
Sign-in logsAuditLog.Read.AllAdmin 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 Scopes

The 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 UTF8

The 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 $props

Recipe 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 -AutoSize

To list the service plans inside a SKU, read its ServicePlans property:

(Get-MgSubscribedSku -All | Where-Object SkuPartNumber -eq 'ENTERPRISEPACK').ServicePlans

Microsoft'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 UTF8

Sorting 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:

PropertyMeaning
LastSignInDateTimeLast interactive sign-in attempt, successful or not
LastNonInteractiveSignInDateTimeLast non-interactive attempt, for example a client refreshing tokens
LastSuccessfulSignInDateTimeLast 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 UTF8

Inactive 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 UTF8

Accounts 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 UTF8

An 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-MgUser call uses -All and an explicit -Property list.
  • Licence reports translate SKU IDs through Get-MgSubscribedSku.
  • Advanced queries use -ConsistencyLevel eventual with -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

Questions people ask

Does MSOnline PowerShell still work?

No. Microsoft's migration FAQ states that the MSOnline and Azure AD PowerShell modules were deprecated on March 30, 2024, and that MSOnline was retired as of May 30, 2025. Scripts that use Connect-MsolService and Get-MsolUser need to move to Microsoft Graph PowerShell or Microsoft Entra PowerShell.

What replaces Get-MsolUser and Get-MsolAccountSku?

Microsoft's cmdlet map lists Get-MgUser as the replacement for Get-MsolUser and Get-MgSubscribedSku for Get-MsolAccountSku. Per-user licence details come from Get-MgUserLicenseDetail, and Set-MsolUserLicense is replaced by Set-MgUserLicense.

Why is signInActivity empty in my Get-MgUser output?

signInActivity is not returned by default; you must request it with -Property. Reading it also requires a Microsoft Entra ID P1 or P2 licence and the AuditLog.Read.All permission. Accounts that have never signed in, or last signed in before April 2020, have no value.

Which permissions do these reports need?

User.Read.All covers user properties and licence details, Organization.Read.All covers the tenant's subscribed SKUs, and AuditLog.Read.All is needed for signInActivity and sign-in logs. For sign-in logs with delegated access, the signed-in admin also needs a role such as Reports Reader, Global Reader or Security Reader.

Microsoft Graph PowerShellEntra IDMicrosoft 365 licensingPowerShell
  1. Group-based licensing in Microsoft 365: setup, conflicts and error fixes

    Assign Microsoft 365 licences through groups, disable service plans per group, move users off direct assignment and fix CountViolation, MutuallyExclusive and usage location errors.

    Microsoft 36511 min read
  2. Microsoft 365 suites with and without Teams: licensing options explained

    How Microsoft 365 and Office 365 suites with and without Teams work after the November 2025 reversal and July 2026 prices, when to add Teams Enterprise, and how to check who is licensed for Teams.

    Microsoft 36510 min read
  3. Move MSOnline and AzureAD scripts to Microsoft Graph PowerShell

    MSOnline and AzureAD PowerShell are retired. Inventory what still calls them, map each cmdlet to Microsoft Graph PowerShell and fix the patterns that break.

    Microsoft 36511 min read