Skip to main content

Automated Guest Account Lifecycle Management in Entra ID

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:

  1. 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.
  2. 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 -WhatIf so 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 real

Prerequisites
#

  • PowerShell 7.x with Microsoft.Graph.Authentication module
  • ImportExcel module (for the reporting script)
  • Interactive sign-in via Connect-MgGraph with these delegated scopes:
    • Reporting: User.Read.All, AuditLog.Read.All
    • Enforcement: User.ReadWrite.All, AuditLog.Read.All
  • Note: Both scripts use the Graph beta endpoint because signInActivity is 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 profileDisableDeleteWhy
Standard enterprise6 months12 monthsGenerous buffer for seasonal collaborations
Regulated (finance, healthcare)90 days6 monthsCompliance typically requires shorter windows
High-churn (contractors, vendors)3 months6 monthsShort engagements, faster cleanup
.\Invoke-InactiveGuestLifecycle.ps1 -DisableThresholdMonths 3 -DeleteThresholdMonths 6

Safe 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 PendingAcceptance for 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. The signInActivity property is only available on the Graph beta endpoint. Beta endpoints can change without notice.
  • User.ReadWrite.All is 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.All grants write access to all user objects, not just guests. The script only targets guests by filtering on userType, but the permission itself is tenant-wide. Restrict who can run this.
  • Always run -WhatIf first. Always. Especially the first time, and especially after changing thresholds.
  • If signInActivity returns 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
#