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.AllandAuditLog.Read.All, which need admin consent the first time you connect
If the SDK is not installed yet:
Install-Module Microsoft.Graph -Scope CurrentUser
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
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:
- Check with managers. Some accounts are used for occasional tasks, or belong to people on long-term leave.
- Block sign-in first. Disabling the account stops any risk immediately and is easy to reverse if someone turns out to need it.
- 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.
- Remove licences. Once the account is blocked and any data is preserved, free up the licence.
- 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)