DEV Community

Cover image for Finding Inactive Microsoft 365 Users with PowerShell and Microsoft Graph
Priya Santos
Priya Santos

Posted on AI-assisted

Finding Inactive Microsoft 365 Users with PowerShell and Microsoft Graph

Every Microsoft 365 tenant collects accounts nobody uses anymore. A contractor finishes a project, a temp leaves after a busy season, or someone sets up a test account and forgets it exists.

Each of those accounts is a problem twice over. It is a security risk, because an account nobody watches is an ideal target for a password spray or phishing attack. It is often a cost too, because many of them still hold a paid licence.

The Microsoft 365 admin centre shows sign-in activity one user at a time, but that does not scale. Here is a PowerShell script that finds every inactive account in one pass using the Microsoft Graph PowerShell SDK.

What you need before you start

The script relies on the signInActivity property in Microsoft Graph, which comes with a few requirements:

  • Microsoft Graph PowerShell SDK installed on your machine
  • Microsoft Entra ID P1 or P2 in the tenant, because sign-in activity is not available on free tenants (Microsoft 365 Business Premium includes P1)
  • Permissions: User.Read.All and AuditLog.Read.All, which need admin consent the first time you connect

If the SDK is not installed yet:

Install-Module Microsoft.Graph -Scope CurrentUser
Enter fullscreen mode Exit fullscreen mode

The script

# Connect with the permissions needed to read users and sign-in activity
Connect-MgGraph -Scopes "User.Read.All", "AuditLog.Read.All"

# Anyone who has not signed in within this many days is flagged
$daysInactive = 90
$cutoff = (Get-Date).AddDays(-$daysInactive)

# Pull every user with the properties we need
$users = Get-MgUser -All -Property Id, DisplayName, UserPrincipalName, UserType, AccountEnabled, CreatedDateTime, AssignedLicenses, SignInActivity |
    Where-Object { $_.UserType -eq 'Member' }

$report = foreach ($user in $users) {

    # Take the most recent of interactive and non-interactive sign-ins
    $lastSignIn = @(
        $user.SignInActivity.LastSignInDateTime,
        $user.SignInActivity.LastNonInteractiveSignInDateTime
    ) | Where-Object { $_ } | Sort-Object -Descending | Select-Object -First 1

    # Skip accounts created recently that may not have signed in yet
    if ($user.CreatedDateTime -gt $cutoff) { continue }

    if (-not $lastSignIn -or $lastSignIn -lt $cutoff) {
        [PSCustomObject]@{
            DisplayName       = $user.DisplayName
            UserPrincipalName = $user.UserPrincipalName
            AccountEnabled    = $user.AccountEnabled
            Licensed          = $user.AssignedLicenses.Count -gt 0
            Created           = $user.CreatedDateTime
            LastSignIn        = $lastSignIn
        }
    }
}

# Save the results and show a quick summary
$report | Sort-Object LastSignIn | Export-Csv -Path .\InactiveUsers.csv -NoTypeInformation

Write-Host "Inactive accounts found: $($report.Count)"
Write-Host "Of which still licensed: $(($report | Where-Object Licensed).Count)"

Disconnect-MgGraph
Enter fullscreen mode Exit fullscreen mode

How the script works

The script connects to Microsoft Graph, pulls every member account along with its sign-in activity, and flags anyone whose most recent sign-in is older than the cutoff. It also flags accounts that have never signed in, unless they were created within the cutoff period and are simply new.

A few details are worth understanding.

Why check non-interactive sign-ins as well?

LastSignInDateTime only records interactive sign-ins, where a person types a password or approves an MFA prompt. Someone who stays signed in to Outlook on their phone may not have an interactive sign-in for weeks, even though they use their account daily. Checking LastNonInteractiveSignInDateTime also prevents active users from being flagged by mistake.

Why exclude guests?

Guest accounts behave differently and are usually reviewed separately, so the script only looks at members. If you want guests included, remove the Where-Object filter on UserType.

Why the licence column matters

The Licensed column is where the quick wins are. An inactive account with no licence is mainly a security clean-up. An inactive account that still holds a Business Premium licence is costing money every month.

What to do with the results

Resist the temptation to delete everything on the list straight away. A safer sequence looks like this:

  1. Check with managers. Some accounts are used for occasional tasks, or belong to people on long-term leave.
  2. Block sign-in first. Disabling the account stops any risk immediately and is easy to reverse if someone turns out to need it.
  3. Keep mailboxes you still need. If a departed user's mailbox holds important history, converting it to a shared mailbox keeps the data accessible, and shared mailboxes up to 50 GB do not need a licence.
  4. Remove licences. Once the account is blocked and any data is preserved, free up the licence.
  5. Delete after a waiting period. Many teams wait 30 days after blocking before deleting, in case anything unexpected comes up.

Things to watch out for

Service accounts. Accounts used by scanners, printers or line-of-business applications may sign in rarely or in unusual ways. Label them clearly so they are not caught in a clean-up.

Data delays. Sign-in activity can take a few hours to update, so a user who signed in this morning may not show it yet. This does not matter for a 90-day check, but keep it in mind if you shorten the window.

Run it regularly. A one-off clean-up helps, but accounts start piling up again straight away. Running the script monthly, or scheduling it with Azure Automation, keeps the tenant tidy with very little effort.

Wrapping up

Inactive accounts are one of the easiest security and cost problems to fix in Microsoft 365, and one of the easiest to forget about. Half an hour with this script usually turns up a few surprises, and often a few licences you can stop paying for.

If you adapt the script, for example, to include guests or send the report by email, I would be interested to hear how you approached it in the comments.

Top comments (0)