Microsoft 365 licence analysis: finding inactive users and wasted licences
A PowerShell script I use in customer tenants to identify inactive users, disabled accounts with licences assigned and concrete opportunities to optimise Microsoft 365 licensing.
In this article12sections
- Introduction
- The problem: licences assigned without real use
- What the script does (functionally)
- Why I use Microsoft Graph
- Output structure and how to use it
- Real-world use cases
- Initial tenant assessment
- Periodic licence optimisation
- Supporting governance and security decisions
- Limitations and important considerations
- PowerShell script
- Conclusion
Introduction
Microsoft 365 licence management is one of the most underrated aspects of corporate tenants.
With most of the customers I work with, the problem isn’t a lack of features but licence sprawl: disabled users who still hold licences, accounts that haven’t signed in for months and licences that keep generating costs without any real use.
That’s why, over time, I’ve developed an analysis script that I use regularly during assessments, governance work or cost-optimisation phases.
The goal isn’t just to “clean up”, but to provide objective data on which to base informed decisions.

The problem: licences assigned without real use
In mature Microsoft 365 tenants, especially those that have grown over time, it’s common to find situations such as:
- disabled users who keep one or more active licences;
- accounts that haven’t signed in for months but are still licensed;
- technical or legacy users that were never really used;
- no centralised view of account inactivity.
These situations have two main impacts:
- financial, because the licences keep being billed;
- security, because unused accounts increase the attack surface.
What the script does (functionally)
The script was created to answer a very simple question:
“How many licences am I paying for that aren’t actually being used?”
Functionally, the script runs three separate analyses on the Microsoft 365 tenant:
-
Disabled users with licences assigned
Accounts that are no longer active but keep consuming licences. -
Licensed users inactive for more than 100 days
Users who haven’t signed in for a long time, despite having one or more active licences. -
Users inactive for more than 100 days (regardless of licences)
A broader view of inactivity, also useful for security and governance assessments.
The results are exported to separate CSV files, ready to be analysed or shared with the customer.
Why I use Microsoft Graph
The script is built on Microsoft Graph, making use in particular of:
- information about the tenant’s users;
- account enabled status;
- licence assignments;
- sign-in activity.
This approach makes it possible to:
- work in read-only mode;
- avoid dependencies on legacy modules;
- use a model consistent with Microsoft best practices.
The required permissions are limited to reading the data needed for the analysis.
Output structure and how to use it
At startup, the script asks for the customer name and creates a dedicated folder for exporting the results.
This approach is particularly useful when working across multiple tenants or in consulting engagements.
Each run generates:
- separate CSV files for each type of analysis;
- data that is easy to filter, aggregate and compare;
- an objective basis for discussing corrective actions with the customer.
The files can be:
- analysed manually;
- imported into Excel or Power BI;
- used as input for further automation.
Real-world use cases
I typically use this script in three main contexts.
Initial tenant assessment
In the early stages of taking on a customer, the script lets me:
- quickly spot obvious waste;
- estimate potential cost savings;
- deliver immediate value, based on concrete data.
Periodic licence optimisation
In medium and large tenants, the script can be run periodically to:
- monitor account inactivity;
- support deprovisioning processes;
- align licence assignment with actual usage.
Supporting governance and security decisions
Inactive users aren’t just a cost issue.
The script’s results are often an excellent starting point for:
- reviewing joiner/mover/leaver processes;
- introducing periodic account review policies;
- reducing the risk associated with unused accounts.
Limitations and important considerations
It’s important to interpret the results correctly.
A user who has been inactive for 100 days should not automatically be removed.
They could be, for example:
- seasonal accounts;
- users on leave;
- technical accounts with occasional sign-ins.
The script doesn’t make decisions; it provides data.
Final responsibility always lies with the IT team or the customer.
PowerShell script
<#
********************************************************************************************************************
#SCRIPT: ANALISI LICENZE & UTENTI INATTIVI
INFO
Author : Bianchi Davide
mail : hello@dbianchi.it
Company : M365 Insight (https://m365insight.dbianchi.it)
Current Version : 0.1
Created : 11/10/2024
Last Updated : 11/10/2024
Request : Get Inactive Users information
********************************************************************************************************************
DESCRIPTION
Questo script estrae dati di analisi utenze per un tenant Office 365, tra cui utenti disabilitati con licenze e
utenti inattivi da oltre 100 giorni. All'avvio, crea una cartella personalizzata dedicata al cliente, in cui
salva i risultati in formato CSV. Lo script richiede l'accesso a Microsoft Graph, utilizzando le autorizzazioni
necessarie per audit e lettura degli utenti del tenant.
Permission Microsoft.Graph: AuditLog.Read.All & User.Read.All
********************************************************************************************************************
VERSION
0.1 | Prima bozza dello script, nessuna implementazione
********************************************************************************************************************
#>
#*******************************************************************************************************************#
#---------------------------------------------------- FUNZIONI -----------------------------------------------------#
#*******************************************************************************************************************#
function Write-Success {
param ([string]$message)
Write-Host $message -ForegroundColor Green
}
#*******************************************************************************************************************#
#---------------------------------------------------- VARIABILI ----------------------------------------------------#
#*******************************************************************************************************************#
#Prendo la data di oggi per ottenere un file diverso ogni volta che viene lanciato lo script nella stessa posizione
$cliente = Read-Host -Prompt "Dichiarare nome cliente"
$OutputFolderPath = "C:\Temp\Office365Assessment_$cliente"
# Controlla se la cartella esiste già, altrimenti la crea
if (-Not (Test-Path -Path $OutputFolderPath)) {
New-Item -ItemType Directory -Path $OutputFolderPath
Write-Host "Cartella creata: $OutputFolderPath"
} else {
Write-Host "La cartella $OutputFolderPath esiste già."
}
#Dichiarazione export file che verranno salvati nella cartella
$LicensedDisabledUsersEx = "$OutputFolderPath\Licensed_Disabled_Users.csv"
$LicensedInactive100daysUsersEx = "$OutputFolderPath\Licensed_Inactive_100days_Users.csv"
$Inactive100daysUsersEx = "$OutputFolderPath\Inactive_100days_Users.csv"
#*******************************************************************************************************************#
#------------------------------------------- MICROSOFT GRAPH CONNECTION --------------------------------------------#
#*******************************************************************************************************************#
#Connessione a Microsoft Graph
$ClientID = "Inserire App ID del cliente"
$TenantID = "Inserire Tenant ID del cliente"
$ClientSecret = "Inserire Client Secret del cliente"
$body = @{
grant_type = "client_credentials"
scope = "https://graph.microsoft.com/.default"
client_id = $ClientID
client_secret = $ClientSecret
}
$oauth = Invoke-RestMethod -Method Post -Uri "https://login.microsoftonline.com/$($TenantID)/oauth2/v2.0/token" -Body $body
$token = @{ 'Authorization' = "$($oauth.token_type) $($oauth.access_token)" }
#New row to fix error in connection to MgGraph version 2.6.1
#Convert token to a secure string
$securetoken = ConvertTo-SecureString "$($oauth.access_token)" -AsPlainText -Force
#Connect to MgGraph using oauth2 token
Connect-MgGraph -AccessToken $securetoken
#*******************************************************************************************************************#
#------------------------------------------ EXPORT LICENSED DISABLED USERS -----------------------------------------#
#*******************************************************************************************************************#
# Ottieni tutti gli utenti disabilitati
$disabledUsers = Get-MgUser -Filter "accountEnabled eq false" -ConsistencyLevel eventual -All -Select UserPrincipalName,DisplayName,AssignedLicenses
# Filtra gli utenti disabilitati per quelli che hanno licenze assegnate
$disabledUsersWithLicenses = $disabledUsers | Where-Object { $_.AssignedLicenses.Count -gt 0 }
# Visualizza i risultati
$disabledUsersWithLicenses | Select-Object UserPrincipalName, DisplayName, AssignedLicenses | Export-Csv -Path $LicensedDisabledUsersEx -Delimiter ';'-NoTypeInformation
Write-Success "Utenti disabilitati esportati in $OutputFolderPath"
#*******************************************************************************************************************#
#-------------------------------------- EXPORT LICENSED INACTIVE 100 DAYS USERS ------------------------------------#
#*******************************************************************************************************************#
# Prendo tutti gli utenti del tenant
$AllUser = Get-MgUser -Property 'SignInActivity,AssignedLicenses' -All | select DisplayName, UserPrincipalName, AssignedLicenses | fl
# Dichiaro un array vuoto per l'object report
$noLoginWithLicensesInfo = @()
# Dichiaro la data di oggi -100 giorni per fare il confronto con il last sign in date
$today = (Get-date).AddDays(-100)
# Inizio il ciclo foreach e ottengo la last logon date e le licenze per ogni utente
foreach ($user in $AllUser) {
$lastSignInDate = $user.SignInActivity.LastSignInDateTime
$assignedLicenses = $user.AssignedLicenses
# Verifico se l'utente non ha fatto login da più di 100 giorni e ha almeno una licenza assegnata
if ($assignedLicenses.Count -gt 0 -and ($null -eq $lastSignInDate -or $lastSignInDate -lt $today)) {
$noLoginWithLicensesInfo += [PSCustomObject]@{
DisplayName = $user.DisplayName
UserPrincipalName = $user.UserPrincipalName
LastSignInDate = if ($null -eq $lastSignInDate) { "Nessun sign-in trovato" } else { $lastSignInDate }
AssignedLicenses = $assignedLicenses.Count
}
}
}
# Esporto tutti i risultati ottenuti in un CSV, e scrivo nella cmdlet che lo script è terminato e dove è stato esportato il file
$noLoginWithLicensesInfo | Export-Csv -Path $LicensedInactive100daysUsersEx -Delimiter ';' -NoTypeInformation
Write-Success "Utenti inattivi esportati in $OutputFolderPath"
#*******************************************************************************************************************#
#------------------------------------------ EXPORT INACTIVE 100 DAYS USERS -----------------------------------------#
#*******************************************************************************************************************#
# Prendo tutti gli utenti del tenant
$AllUser = Get-MgUser -Property 'SignInActivity' -All | select DisplayName, UserPrincipalName, SignInActivity
# Dichiaro un array vuoto per l'object report
$noLoginInfo = @()
# Dichiaro la data di oggi -100 giorni per fare il confronto con la last sign in date
$today = (Get-date).AddDays(-100)
# Inizio il ciclo foreach e ottengo la last logon date per ogni utente
foreach ($user in $AllUser) {
$lastSignInDate = $user.SignInActivity.LastSignInDateTime
# Verifico se l'utente non ha fatto login da più di 100 giorni
if ($null -eq $lastSignInDate -or $lastSignInDate -lt $today) {
$noLoginInfo += [PSCustomObject]@{
DisplayName = $user.DisplayName
UserPrincipalName = $user.UserPrincipalName
LastSignInDate = if ($null -eq $lastSignInDate) { "Nessun sign-in trovato" } else { $lastSignInDate }
}
}
}
# Esporto tutti i risultati ottenuti in un CSV e scrivo nella cmdlet che lo script è terminato
$noLoginInfo | Export-Csv -Path $Inactive100daysUsersEx -Delimiter ';' -NoTypeInformation
Write-Success "Utenti inattivi esportati in $OutputFolderPath"
#*******************************************************************************************************************#
#------------------------------------------------------ FINE SCRIPT ------------------------------------------------#
#*******************************************************************************************************************#
Write-Host "Risultati esportati in $OutputFolderPath" -Foregroundcolor Green
Write-Success "Tutti i file di assessment sono pronti per le analisi. Lo script è terminato!"
Conclusion
Effective Microsoft 365 licence management doesn’t just come from the admin portal, but from real usage data.
This script was created with exactly that goal: turning technical information into concrete decisions, reducing waste and improving tenant governance.
It isn’t a “magic” tool, but a solid foundation on which to build more mature identity and licence management processes in Microsoft 365.