The Problem#
Every B2B collaboration, vendor engagement, and external consultant invitation creates a guest account in your Entra ID tenant. Over time, these accumulate. People leave partner organizations, projects end, contracts expire – but the guest accounts persist. They retain whatever access they were granted, their tokens remain valid, and nobody reviews them.
Access reviews help if someone’s running them. Usually, nobody is.
How It Works#
Two scripts, one workflow:
Get-GuestsInfo.ps1– the reporting script. Pulls every guest account, resolves their manager, checks when they last actually did anything, and dumps the result into Excel. Run this first to understand your population.Invoke-InactiveGuestLifecycle.ps1– the enforcement script. Applies a two-stage lifecycle: disable at 6 months inactive, delete at 12 months (and only if already disabled). Supports-WhatIfso you can preview everything before committing.
The intended workflow: report, review, preview, enforce.
Get-GuestsInfo.ps1 # understand your tenant
Invoke-InactiveGuestLifecycle.ps1 -WhatIf # preview what would happen
Invoke-InactiveGuestLifecycle.ps1 # do it for realPrerequisites#
- PowerShell 7.x with
Microsoft.Graph.Authenticationmodule ImportExcelmodule (for the reporting script)- Interactive sign-in via
Connect-MgGraphwith these delegated scopes:- Reporting:
User.Read.All,AuditLog.Read.All - Enforcement:
User.ReadWrite.All,AuditLog.Read.All
- Reporting:
- Note: Both scripts use the Graph beta endpoint because
signInActivityis only available there. Beta endpoints can change without notice.
Implementation#
Fetching Guest Data with Sign-In Activity#
The Graph query requests signInActivity alongside basic user properties. This property is only available on the beta endpoint:
$allGuests = [System.Collections.Generic.List[object]]::new()
$uri = "https://graph.microsoft.com/beta/users?" +
"`$filter=userType eq 'Guest'" +
"&`$select=id,userPrincipalName,mail,creationType,createdDateTime," +
"signInActivity,accountEnabled,userType,externalUserState"
do {
$response = Invoke-GraphGetSafe -Uri $uri
if ($response.value) {
foreach ($item in $response.value) { $allGuests.Add($item) }
}
$uri = $response.'@odata.nextLink'
} while ($uri)The signInActivity property provides lastSignInDateTime (interactive) and lastNonInteractiveSignInDateTime (service/daemon). The script takes whichever is more recent:
$lastSignIn = if ($lastNonInteractive -gt $lastInteractive) {
$lastNonInteractive
} else {
$lastInteractive
}This matters. A guest that authenticates via a service principal or automated flow is still active, even if the human hasn’t opened a browser in months.
Lifecycle Classification#
Each guest gets classified based on configurable thresholds. If the guest has never signed in, the script falls back to createdDateTime – an invitation sent 8 months ago that was never accepted is not “active,” it’s abandoned:
$baselineDate = if ($null -ne $lastSignIn) { [datetime]$lastSignIn }
else { [datetime]$_.createdDateTime }
$action = 'None'
if ($baselineDate -lt $disableDate) { $action = 'Disable' }
if ($baselineDate -lt $deleteDate) { $action = 'Delete' }Choosing Thresholds#
The defaults are 6/12 months. Adjust based on your environment:
| Tenant profile | Disable | Delete | Why |
|---|---|---|---|
| Standard enterprise | 6 months | 12 months | Generous buffer for seasonal collaborations |
| Regulated (finance, healthcare) | 90 days | 6 months | Compliance typically requires shorter windows |
| High-churn (contractors, vendors) | 3 months | 6 months | Short engagements, faster cleanup |
.\Invoke-InactiveGuestLifecycle.ps1 -DisableThresholdMonths 3 -DeleteThresholdMonths 6Safe Disable Logic#
The disable operation guards against disabling guests still in PendingAcceptance state. Disabling a pending invitation creates confusion for everyone involved – the guest gets an error when they click the invite link, and the host has no idea why:
$guestsToDisable = @($guestReport | Where-Object {
$_.Action -eq 'Disable' -and
$_.AccountEnabled -eq $true -and
$_.ExternalUserState -ne 'PendingAcceptance'
})Two-Stage Safety#
Deletion requires two conditions: the account must be past the delete threshold AND already disabled:
$guestsToDelete = @($guestReport | Where-Object {
$_.Action -eq 'Delete' -and
$_.AccountEnabled -eq $false
})A guest cannot be deleted unless it was first disabled in a previous cycle. This gives you a window (between disable at month 6 and delete at month 12) where the account exists but is disabled – enough time for someone to notice and re-enable it if needed.
The Reporting Script#
Get-GuestsInfo.ps1 is the reconnaissance tool. It resolves each guest’s manager (so you know who invited them), checks sign-in age, and exports everything to Excel:
# Resolve manager for each guest
$mgr = Invoke-GraphGetSafe -Uri `
"https://graph.microsoft.com/v1.0/users/$($_.id)/manager?`$select=userPrincipalName"
[PSCustomObject][ordered]@{
Id = $_.id
UserPrincipalName = $_.userPrincipalName
Mail = $_.mail
CreatedDateTime = $_.createdDateTime
LastSignIn = if ($lastSignIn) { ([datetime]$lastSignIn).ToString('yyyy-MM-dd') } else { 'N/A' }
AccountEnabled = $_.accountEnabled
Manager = if ($mgr) { $mgr.userPrincipalName } else { '' }
}Run this before enforcement. Look at the LastSignIn column. If every guest shows N/A, your tenant may not have AuditLog.Read.All consented or your Entra ID license doesn’t include sign-in logs – fix that before you run the lifecycle script, or you’ll disable everyone based on creation date alone.
What to Look For#
When reviewing the guest report:
- Guests with no sign-in and old creation dates – abandoned invitations. Safe to clean up.
- Guests with recent sign-ins but no manager – active but unsponsored. Someone should own this relationship.
- Guests in
PendingAcceptancefor months – the invitation was never accepted. Consider revoking and re-inviting, or just cleaning up. - Large numbers of guests from a single domain – could be a former vendor relationship. Worth reviewing as a batch.
Known Limitations#
- Beta API only for
signInActivity. ThesignInActivityproperty is only available on the Graph beta endpoint. Beta endpoints can change without notice. User.ReadWrite.Allis required for enforcement and cannot be narrowed. There is no more granular built-in permission that covers both disabling and deleting user objects via Graph. If you need tighter control, restrict who can run the script rather than trying to scope the permission down.- Manager resolution makes individual API calls per guest. In tenants with thousands of guests, the reporting script will be slower due to per-guest manager lookups. The enforcement script does not resolve managers and runs faster.
- Deleted guests go to the recycle bin for 30 days. This is your last safety net. After 30 days, deletion is permanent.
Security Considerations#
User.ReadWrite.Allgrants write access to all user objects, not just guests. The script only targets guests by filtering onuserType, but the permission itself is tenant-wide. Restrict who can run this.- Always run
-WhatIffirst. Always. Especially the first time, and especially after changing thresholds. - If
signInActivityreturns null for every guest, stop. Do not proceed with enforcement. This usually means a licensing or permissions issue, and running the lifecycle script in this state would classify every guest as inactive based on creation date alone.
GitHub#
- Get-GuestsInfo.ps1 – reporting script
- Invoke-InactiveGuestLifecycle.ps1 – enforcement script