Stale accounts are the quietest attack surface in any tenant. This is the
script I reach for when an audit turns up a pile of “hasn’t logged in since
last year” objects. It runs dry by default — nothing changes until you pass
-Commit.
# Requires: Microsoft.Graph module, Directory.ReadWrite.All + AuditLog.Read.All
param(
[int]$InactiveDays = 90,
[switch]$Commit
)
Connect-MgGraph -Scopes "User.ReadWrite.All","AuditLog.Read.All" | Out-Null
$cutoff = (Get-Date).AddDays(-$InactiveDays).ToString("o")
$stale = Get-MgUser -All -Property "id,displayName,userPrincipalName,accountEnabled,signInActivity" |
Where-Object {
$_.AccountEnabled -and
$_.SignInActivity.LastSignInDateTime -and
$_.SignInActivity.LastSignInDateTime -lt $cutoff
}
"Found $($stale.Count) accounts inactive > $InactiveDays days" | Write-Host -ForegroundColor Yellow
foreach ($u in $stale) {
"{0,-40} last: {1}" -f $u.UserPrincipalName, $u.SignInActivity.LastSignInDateTime | Write-Host
if ($Commit) {
Update-MgUser -UserId $u.Id -AccountEnabled:$false
Add-Content -Path "./disabled-$(Get-Date -f yyyyMMdd).log" -Value $u.UserPrincipalName
}
}
if (-not $Commit) { Write-Host "`nDRY RUN. re-run with -Commit to apply." -ForegroundColor Cyan }
Notes that bit me: signInActivity requires an Entra ID P1 license on the
tenant and the AuditLog.Read.All scope, or the property comes back null
and every account looks “stale.” Always confirm the property populates
before you trust the filter.