Requirements
Microsoft.Graph.Authentication; delegated sign-in with required consent; appropriate tenant service availability.
Permissions
User.Read.All, AuditLog.Read.All
PowerShell source / read-only remote queries
Identify potentially stale accounts from sign-in activity; this is review input, not automatic deletion.
Microsoft.Graph.Authentication; delegated sign-in with required consent; appropriate tenant service availability.
User.Read.All, AuditLog.Read.All
Local CSV reports and console inventory. Tenant reads are read-only; creating report directories and files writes to the local filesystem.
Review against your environment before execution. Reports may contain personal or tenant identifiers. Do not publish live exports. This page does not run commands.
<#
.SYNOPSIS
Reports potentially stale Microsoft Entra user accounts.
#>
$ErrorActionPreference = "Stop"
$ProjectRoot = Split-Path $PSScriptRoot -Parent
$ReportsPath = Join-Path $ProjectRoot "reports"
if (-not (Test-Path $ReportsPath)) { New-Item -ItemType Directory -Path $ReportsPath -Force | Out-Null }
Import-Module Microsoft.Graph.Authentication -ErrorAction Stop
$RequiredScopes = @("User.Read.All","AuditLog.Read.All")
$Context=Get-MgContext
$ReconnectRequired=$false
if(-not $Context){$ReconnectRequired=$true}
else{
$CurrentScopes=@($Context.Scopes)
$MissingScopes=@($RequiredScopes | Where-Object {$_ -notin $CurrentScopes})
if($MissingScopes.Count -gt 0){$ReconnectRequired=$true}
}
if($ReconnectRequired){
if($Context){ try{Disconnect-MgGraph | Out-Null}catch{} }
Connect-MgGraph -Scopes $RequiredScopes -UseDeviceCode -NoWelcome
$Context=Get-MgContext
}
if(-not $Context){throw "Microsoft Graph authentication could not be established."}
$MissingScopes=@($RequiredScopes | Where-Object {$_ -notin @($Context.Scopes)})
if($MissingScopes.Count -gt 0){throw "Required scopes are missing: $($MissingScopes -join ', ')"}
function Get-GraphCollection {
param([Parameter(Mandatory=$true)][string]$Uri)
$Results=@();$NextUri=$Uri
while($NextUri){
$Response=Invoke-MgGraphRequest -Method GET -Uri $NextUri
if($Response["value"]){$Results+=@($Response["value"])}
$NextUri=$Response["@odata.nextLink"]
}
return $Results
}
$UserUri="https://graph.microsoft.com/v1.0/users?`$select=id,displayName,userPrincipalName,accountEnabled,userType,createdDateTime,onPremisesSyncEnabled,signInActivity&`$top=999"
$Users=@(Get-GraphCollection -Uri $UserUri)
$Now=Get-Date
$StaleThresholdDays=90
$AccountInventory=@(
foreach($User in $Users){
$CreatedDate=$null;$LastSignIn=$null;$DaysSinceLastSignIn=$null;$AccountAgeDays=$null
if($User["createdDateTime"]){
$CreatedDate=[datetime]$User["createdDateTime"]
$AccountAgeDays=[int](New-TimeSpan -Start $CreatedDate -End $Now).TotalDays
}
if($User["signInActivity"] -and $User["signInActivity"]["lastSignInDateTime"]){
$LastSignIn=[datetime]$User["signInActivity"]["lastSignInDateTime"]
$DaysSinceLastSignIn=[int](New-TimeSpan -Start $LastSignIn -End $Now).TotalDays
}
$StaleReason=@()
if($User["accountEnabled"] -eq $false){$StaleReason+="Account disabled"}
if($null -ne $DaysSinceLastSignIn -and $DaysSinceLastSignIn -ge $StaleThresholdDays){
$StaleReason+="No sign-in for $DaysSinceLastSignIn days"
}
if($null -eq $LastSignIn -and $null -ne $AccountAgeDays -and $AccountAgeDays -ge $StaleThresholdDays){
$StaleReason+="No recorded sign-in and account older than $StaleThresholdDays days"
}
[PSCustomObject]@{
DisplayName=$User["displayName"];UserPrincipalName=$User["userPrincipalName"]
AccountEnabled=$User["accountEnabled"];UserType=$User["userType"]
CreatedDateTime=$CreatedDate;AccountAgeDays=$AccountAgeDays
LastSignInDateTime=$LastSignIn;DaysSinceLastSignIn=$DaysSinceLastSignIn
OnPremisesSyncEnabled=$User["onPremisesSyncEnabled"]
PotentiallyStale=($StaleReason.Count -gt 0);StaleReason=($StaleReason -join "; ")
UserId=$User["id"]
}
}
)
$ReportPath=Join-Path $ReportsPath "Entra-Stale-Accounts.csv"
$AccountInventory | Sort-Object DisplayName | Export-Csv -Path $ReportPath -NoTypeInformation -Encoding UTF8
Write-Host "Total Users: $($AccountInventory.Count)"
Write-Host "Potentially Stale: $(@($AccountInventory | Where-Object PotentiallyStale -eq $true).Count)"
Write-Host "Disabled Accounts: $(@($AccountInventory | Where-Object AccountEnabled -eq $false).Count)"
Write-Host "No Recorded Sign-In: $(@($AccountInventory | Where-Object {$null -eq $_.LastSignInDateTime}).Count)"
Write-Host "Stale Threshold (Days): $StaleThresholdDays"
Write-Host "Report exported to: $ReportPath" -ForegroundColor Green