How We Cleaned Up 1,000+ Stale M365 Guest Accounts Without Breaking Production (PowerShell Script Included)

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:

  1. 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.
  2. 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

  1. The “Never Logged In” Trap: When guests are invited, their SignInActivity timestamp is empty (null) until their first successful interactive login. Falling back to $User.CreatedDateTime prevents newly created vendor invites from getting disabled before the onboarding period expires.
  2. Use LastSuccessfulSignInDateTime: Avoid filtering on LastSignInDateTime. The latter updates on failed authentication attempts. If an attacker attempts to brute-force a dormant guest account, LastSignInDateTime will update, making a stale account look “active”.
  3. Required API Scopes: You must include AuditLog.Read.All in your Connect-MgGraph scope request. Without it, Microsoft Graph silently returns null for all sign-in timestamps.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top