Licenses tend to stay assigned long after someone stops using them. Every one of them costs money, and an account nobody uses is one more thing to keep track of. This short script lists the licensed, enabled accounts in your Microsoft 365 tenant that haven’t had a successful sign-in in the last 30 days, shows which licenses each one holds, and saves the list to a CSV.
What you need
- PowerShell 7 and the Microsoft Graph PowerShell SDK, including the
Microsoft.Graph.Usersmodule. - A Microsoft Entra ID P1 or P2 license. Microsoft documents that the sign-in activity details this script depends on require one.
- The read-only
User.Read.AllandAuditLog.Read.Allpermissions, and a signed-in account with an Entra role that can read sign-in logs. Reading license details also takes a role such as Directory Readers, and Microsoft Learn lists the supported roles.
The script
Connect-MgGraph `
-Scopes "User.Read.All","AuditLog.Read.All" `
-NoWelcome
Write-Host "Running... Please wait. This may take a while."
$DaysInactive = 30
$CutoffDate = (Get-Date).AddDays(-$DaysInactive)
try {
$Users = Get-MgUser `
-Filter "userType eq 'Member'" `
-All `
-Property Id,DisplayName,UserPrincipalName,AccountEnabled,AssignedLicenses,CreatedDateTime,SignInActivity
}
catch {
Write-Warning "Could not read users: $($_.Exception.Message)"
Write-Warning "Sign-in activity needs Microsoft Entra ID P1 or P2 and the AuditLog.Read.All permission."
return
}
$Results = foreach ($User in $Users) {
if (!$User.AccountEnabled) { continue }
if (!$User.AssignedLicenses -or $User.AssignedLicenses.Count -eq 0) {
continue
}
# Skip accounts created inside the window. They may simply not have signed in yet.
if ($User.CreatedDateTime -and [datetime]$User.CreatedDateTime -ge $CutoffDate) { continue }
# Last successful sign-in, interactive or not. Failed attempts do not count.
$LastSignIn = $null
if ($User.SignInActivity.LastSuccessfulSignInDateTime) {
$LastSignIn = [datetime]$User.SignInActivity.LastSuccessfulSignInDateTime
}
if ($LastSignIn -and $LastSignIn -ge $CutoffDate) {
continue
}
try {
$Licenses = (
Get-MgUserLicenseDetail -UserId $User.Id |
Select-Object -ExpandProperty SkuPartNumber -Unique |
Sort-Object
) -join "; "
}
catch {
$Licenses = "Unable to retrieve"
}
[PSCustomObject]@{
DisplayName = $User.DisplayName
UserPrincipalName = $User.UserPrincipalName
LastSignIn = if ($LastSignIn) {
$LastSignIn.ToString("yyyy-MM-dd")
}
else {
"None recorded"
}
DaysInactive = if ($LastSignIn) {
((Get-Date) - $LastSignIn).Days
}
else {
$null
}
Licenses = $Licenses
}
}
if (-not $Results) {
Write-Host "No inactive licensed users were found."
return
}
# Accounts with no recorded sign-in are the most inactive, so list them first.
$Results =
$Results |
Sort-Object @{ Expression = { if ($null -eq $_.DaysInactive) { [int]::MaxValue } else { $_.DaysInactive } }; Descending = $true }
$Results |
Export-Csv `
-Path ".\InactiveLicensedUsers.csv" `
-NoTypeInformation `
-Encoding UTF8
$Results |
Format-Table `
DisplayName,
UserPrincipalName,
LastSignIn,
DaysInactive,
Licenses `
-AutoSize `
-Wrap
Write-Host ""
Write-Host "Found $(@($Results).Count) inactive licensed users." -ForegroundColor Green
Write-Host "CSV exported to InactiveLicensedUsers.csv" -ForegroundColor Green
Disconnect-MgGraph | Out-Null
What it does
- Signs in to Microsoft Graph with the read-only permissions above and prints a short message, because it can take a while in a large tenant.
- Gets every member account along with its sign-in activity. Guest accounts are left out.
- Skips accounts that are disabled, accounts with no license, and accounts created in the last 30 days, since a new hire may simply not have signed in yet.
- Keeps the accounts whose last successful sign-in is older than 30 days, or that have none on record, and looks up the licenses each one holds.
- Sorts the list so accounts with no recorded sign-in come first, then the longest inactive. It saves the list to
InactiveLicensedUsers.csvin the folder where you run the script and shows it on screen as a table. - If no account qualifies, it says so and doesn’t write a CSV. If it can’t read the users, it tells you what the script needs. To change the 30-day window, edit
$DaysInactiveat the top. - Only reads data. It doesn’t change any account or license.
Why last successful sign-in
Microsoft’s sign-in activity data has more than one date, and the choice matters. lastSignInDateTime is the last interactive sign-in attempt, and it counts failed attempts too, so someone guessing at an unused account’s password makes it look active. It also misses people who only sign in non-interactively, such as a phone app refreshing its token in the background. lastSuccessfulSignInDateTime is the most recent successful sign-in of either kind, which is a better measure of whether an account is really being used.
Check before you remove a license
An account on this list is a candidate for review, not a verdict. A few things to keep in mind:
- “None recorded” has two meanings. The account may never have signed in, or its last successful sign-in may be from before December 1, 2023, when Microsoft started recording this date. It wasn’t backfilled. Either way, nothing has signed in during your window.
- The data can lag. Microsoft says sign-in activity can take up to 24 hours to update, so someone who signed in a few hours ago may still appear.
- Inactive isn’t always unused. Licensed service accounts, shared mailboxes with a license, emergency access accounts and people on extended leave can all show up here. Check with the person’s manager before you take a license away.
The script is in my GitHub, along with other scripts for Microsoft 365 admins. It is free and open source under the MIT license.