How to Find Inactive Licensed Users in Microsoft 365

A short PowerShell script that lists licensed Microsoft 365 users who haven't signed in for 30 days, so you can find licenses you pay for but don't use.

Written by: Justin

Published on: October 26, 2026

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

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.csv in 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 $DaysInactive at 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.

Leave a Comment

Previous

Get ready now for the EWS end-of-life on your M365 tenant.