Stale accounts — users who have not signed in for months, disabled accounts that were never cleaned up, guest accounts long forgotten — are a quiet but serious risk. They expand your attack surface and clutter your audits. Detecting them by hand does not scale. Here is how to automate it with supported tooling.
First: stop using AzureAD and MSOnline
If your existing scripts use the AzureAD or MSOnline PowerShell modules, they are living on borrowed time. Microsoft has deprecated these modules. The supported, long-term path is the Microsoft Graph PowerShell SDK, which talks to the same Graph API that powers the Entra portal.
Migrating is worth doing now, on your terms, rather than scrambling when the old modules stop working.
# Install once
Install-Module Microsoft.Graph -Scope CurrentUser
# Connect with least-privilege scopes
Connect-MgGraph -Scopes "User.Read.All", "AuditLog.Read.All"
Finding stale accounts
Entra ID tracks sign-in activity on each user via the signInActivity property. We can query all enabled members and flag anyone whose last successful interactive sign-in is older than our threshold.
$thresholdDays = 90
$cutoff = (Get-Date).AddDays(-$thresholdDays)
$staleUsers =
Get-MgUser -All `
-Property "displayName,userPrincipalName,accountEnabled,signInActivity" `
-Filter "accountEnabled eq true and userType eq 'Member'" |
Where-Object {
$last = $_.SignInActivity.LastSignInDateTime
# No sign-in on record, or older than the cutoff
-not $last -or $last -lt $cutoff
} |
Select-Object DisplayName, UserPrincipalName,
@{ Name = "LastSignIn"; Expression = { $_.SignInActivity.LastSignInDateTime } }
$staleUsers | Sort-Object LastSignIn |
Export-Csv -Path "./stale-users.csv" -NoTypeInformation
A few things to note:
- We request only the properties we need with
-Property, which keeps the query fast. - We filter server-side with
-Filterwhere possible, rather than pulling everything and filtering in PowerShell. - Accounts with no
LastSignInDateTimeare often never-used accounts — frequently the most interesting ones.
Run it unattended, securely
For scheduled execution (Azure Automation, an Azure Function, or a server task), do not embed credentials. Use one of:
- Managed identity (when running inside Azure) — no secrets at all.
- Certificate-based app registration with a least-privilege application permission set.
Connect-MgGraph -ClientId $appId -TenantId $tenantId -CertificateThumbprint $thumb
Grant the app only AuditLog.Read.All and User.Read.All. Never grant broad write permissions to a reporting job.
Turn the report into action
A CSV in a folder helps no one. Wire the output into a workflow:
- Email the report to identity owners on a schedule.
- Open a ticket for accounts past a hard threshold (e.g., 180 days).
- After a grace period and owner confirmation, disable (not delete) stale accounts, then delete after a further retention window.
The principle is the same one I bring to every automation engagement: start with a one-time cleanup, then build the recurring automation that prevents the mess from coming back — all of it source-controlled, parameterized, and documented so your team can maintain it.
If your identity team is drowning in manual reports, let's talk. Automation is one of the fastest wins available.