Introduction: The Hidden Risk of “Guest Debt”
If you have run a Microsoft 365 tenant for more than a few months, you already know how fast external access gets out of hand. A temporary vendor project starts, contractors join Teams channels, external partners get SharePoint permissions, and everyone gets to work.
Then the project ends. The contractors leave, but their guest accounts remain in Microsoft Entra ID (Azure AD) indefinitely. In enterprise IT, we call this “Guest Debt.”
When we conducted a tenant-wide identity audit prior to rolling out Microsoft 365 Copilot, the numbers were eye-opening: over 1,000 guest accounts had not logged in for 180+ days. Worse, dozens of these dormant accounts still held active read access to sensitive project repositories, internal wikis, and historical files.
Cleaning this up manually via the Entra ID portal is time-consuming and error-prone. To solve this, we built a two-stage automated PowerShell process using Microsoft Graph SDK to audit, disable, and safely purge stale guest accounts.
Why a “Two-Stage” Clean-up is Non-Negotiable
Never run a script that hard-deletes inactive users on day one. You will eventually disable or delete an external board member, auditor, or executive advisor who only logs in once or twice a year.
Our operational pipeline uses a strict two-stage grace period:
- Stage 1 (Day 0): Query all guest users, filter out inactive accounts (>90 days), export an auditable CSV report, and block sign-in (
AccountEnabled = $false). This revokes access immediately without deleting any underlying profile data or ownership history. - Stage 2 (Day 30): If no support tickets or access restoration requests are submitted during the 30-day grace period, the accounts are safely deleted.
The Script: Production-Grade Graph PowerShell
Save this file locally as Invoke-GuestAccountCleanup.ps1.
<#
.SYNOPSIS
Audits and disables inactive Microsoft Entra ID Guest accounts.
.NOTES
Author: Innovation Guru (dsinnovationguru.com)
Prerequisites: Microsoft.Graph.Users module installed.
Required Scopes: User.ReadWrite.All, AuditLog.Read.All
#>
[CmdletBinding()]
param (
[int]$InactivityDays = 90,
[string]$ReportPath = "C:\Reports\GuestCleanup_$(Get-Date -Format 'yyyyMMdd').csv",
[switch]$WhatIfMode = $true, # Set to $false to execute real changes
[array]$ExcludedDomains = @("trustedpartner.com", "keyadvisor.org") # Domain exclusions
)
# 1. Connect to Microsoft Graph
Write-Host "Connecting to Microsoft Graph..." -ForegroundColor Cyan
Connect-MgGraph -Scopes "User.ReadWrite.All", "AuditLog.Read.All" -NoWelcome
# 2. Fetch Guest Accounts
Write-Host "Querying Guest identities from Entra ID..." -ForegroundColor Cyan
$GuestUsers = Get-MgUser -Filter "userType eq 'Guest'" -Property Id, DisplayName, UserPrincipalName, Mail, AccountEnabled, SignInActivity, CreatedDateTime -All
$Results = @()
$CutoffDate = (Get-Date).AddDays(-$InactivityDays)
foreach ($User in $GuestUsers) {
# Check domain exclusions
$UserDomain = ($User.UserPrincipalName -split "@")[1]
if ($ExcludedDomains -contains $UserDomain) {
Write-Host "Skipping excluded domain: $($User.UserPrincipalName)" -ForegroundColor Yellow
continue
}
# Evaluate sign-in activity
$LastSignIn = $User.SignInActivity.LastSuccessfulSignInDateTime
# Edge Case: Handle guests invited who never signed in
if (-not $LastSignIn) {
$EffectiveDate = $User.CreatedDateTime
$Status = "Never Signed In"
} else {
$EffectiveDate = $LastSignIn
$Status = "Active Login Recorded"
}
# Compare against cutoff threshold
if ($EffectiveDate -lt $CutoffDate) {
$DaysInactive = [math]::Round(((Get-Date) - $EffectiveDate).TotalDays)
$LogEntry = [PSCustomObject]@{
UserId = $User.Id
DisplayName = $User.DisplayName
UPN = $User.UserPrincipalName
Email = $User.Mail
AccountEnabled = $User.AccountEnabled
LastSignInDate = $LastSignIn
CreatedDate = $User.CreatedDateTime
DaysInactive = $DaysInactive
Status = $Status
ActionTaken = "None"
}
if (-not $WhatIfMode) {
if ($User.AccountEnabled -eq $true) {
# Disable user account
Update-MgUser -UserId $User.Id -AccountEnabled:$false
$LogEntry.ActionTaken = "Disabled"
Write-Host "Disabled account: $($User.UserPrincipalName) (Inactive $DaysInactive days)" -ForegroundColor Red
} else {
$LogEntry.ActionTaken = "Already Disabled"
}
} else {
$LogEntry.ActionTaken = "WhatIf - Would Disable"
Write-Host "[WhatIf] Identified inactive guest: $($User.UserPrincipalName) ($DaysInactive days)" -ForegroundColor Yellow
}
$Results += $LogEntry
}
}
# 3. Export CSV Audit Trail
$Results | Export-Csv -Path $ReportPath -NoTypeInformation
Write-Host "`nCleanup audit run complete. Output saved to: $ReportPath" -ForegroundColor Green
Real-World Production Gotchas
- The “Never Logged In” Trap: When guests are invited, their
SignInActivitytimestamp is empty (null) until their first successful interactive login. Falling back to$User.CreatedDateTimeprevents newly created vendor invites from getting disabled before the onboarding period expires. - Use
LastSuccessfulSignInDateTime: Avoid filtering onLastSignInDateTime. The latter updates on failed authentication attempts. If an attacker attempts to brute-force a dormant guest account,LastSignInDateTimewill update, making a stale account look “active”. - Required API Scopes: You must include
AuditLog.Read.Allin yourConnect-MgGraphscope request. Without it, Microsoft Graph silently returnsnullfor all sign-in timestamps.
