Guest user sprawl is one of the most common compliance headaches in Microsoft 365 environments. External collaborators get invited, projects end, and those guest accounts linger indefinitely with access they no longer need. Left unchecked, stale guests create unnecessary attack surface and complicate audits. In this post, you will learn how to use PowerShell and the Microsoft Graph API to automate the full guest user lifecycle: discovery, review, expiry tracking, and removal.

Prerequisites

  • PowerShell 7.x (recommended) or Windows PowerShell 5.1
  • Microsoft Graph PowerShell SDK: Install-Module Microsoft.Graph -Scope CurrentUser
  • An Entra ID app registration with these API permissions (Application): User.Read.All, AuditLog.Read.All, Directory.ReadWrite.All
  • Global Administrator or User Administrator role for the service principal

Connecting to Microsoft Graph

Start by authenticating with a client credential flow using your app registration. Store your secrets in environment variables or Azure Key Vault rather than hardcoding them.

# Connect using a service principal (client credentials)
$tenantId     = $env:ENTRA_TENANT_ID
$clientId     = $env:ENTRA_CLIENT_ID
$clientSecret = $env:ENTRA_CLIENT_SECRET | ConvertTo-SecureString -AsPlainText -Force

$credential = [System.Management.Automation.PSCredential]::new($clientId, $clientSecret)

Connect-MgGraph -TenantId $tenantId -ClientSecretCredential $credential -NoWelcome
Write-Host "Connected to tenant: $tenantId"

Discovering All Guest Users

Guest accounts in Entra ID have the userType property set to Guest. The query below retrieves all guests along with their sign-in activity, which is essential for determining whether an account is still in use.

# Retrieve all guest users with last sign-in data
$guests = Get-MgUser -Filter "userType eq 'Guest'" -All `
    -Property Id, DisplayName, Mail, UserPrincipalName, CreatedDateTime, `
             SignInActivity, ExternalUserState, AccountEnabled

Write-Host "Total guests found: $($guests.Count)"

# Build a report object
$report = $guests | ForEach-Object {
    $lastSignIn = $_.SignInActivity.LastSignInDateTime
    $daysSince  = if ($lastSignIn) {
        (New-TimeSpan -Start $lastSignIn -End (Get-Date)).Days
    } else { 9999 }

    [PSCustomObject]@{
        DisplayName       = $_.DisplayName
        Mail              = $_.Mail
        UPN               = $_.UserPrincipalName
        CreatedDate       = $_.CreatedDateTime
        LastSignIn        = $lastSignIn
        DaysSinceSignIn   = $daysSince
        ExternalUserState = $_.ExternalUserState
        AccountEnabled    = $_.AccountEnabled
        Id                = $_.Id
    }
}

# Export for review
$report | Export-Csv -Path ".\guest-report-$(Get-Date -Format 'yyyyMMdd').csv" -NoTypeInformation
Write-Host "Report exported."

Identifying Stale Guests

A common policy is to flag guests who have not signed in for 90 days, and to flag any guest whose invitation is still pending after 30 days. The snippet below applies both rules and outputs a consolidated stale list.

# Thresholds (days)
$inactiveDays  = 90
$pendingDays   = 30

$stale = $report | Where-Object {
    # Never signed in and account is older than the pending threshold
    ($_.DaysSinceSignIn -eq 9999 -and
     (New-TimeSpan -Start $_.CreatedDate -End (Get-Date)).Days -gt $pendingDays) -or
    # Signed in previously but now inactive beyond threshold
    ($_.DaysSinceSignIn -ne 9999 -and $_.DaysSinceSignIn -gt $inactiveDays)
}

Write-Host "Stale guests identified: $($stale.Count)"
$stale | Format-Table DisplayName, Mail, DaysSinceSignIn, ExternalUserState -AutoSize

Sending a Review Notification Before Removal

Rather than deleting accounts immediately, a best-practice workflow sends a notification to the inviting manager or a designated mailbox so a human can confirm the removal. This example uses the Graph API to send mail via a shared mailbox.

function Send-GuestReviewEmail {
    param(
        [string]$ToAddress,
        [string]$GuestName,
        [string]$GuestMail,
        [int]$DaysInactive,
        [string]$FromAddress = "[email protected]"
    )

    $body = @{
        message = @{
            subject = "Action Required: Stale guest account - $GuestName"
            body    = @{
                contentType = "HTML"
                content     = "<p>Hello,</p><p>The guest account <strong>$GuestName</strong> ($GuestMail) has not signed in for <strong>$DaysInactive days</strong> and is scheduled for removal in 14 days.</p><p>If this account should be retained, please reply to this message or update the access review in the Entra ID portal.</p>"
            }
            toRecipients = @(
                @{ emailAddress = @{ address = $ToAddress } }
            )
        }
    } | ConvertTo-Json -Depth 10

    Invoke-MgGraphRequest -Method POST `
        -Uri "https://graph.microsoft.com/v1.0/users/$FromAddress/sendMail" `
        -Body $body -ContentType "application/json"
}

# Example: notify for each stale guest
foreach ($guest in $stale) {
    Send-GuestReviewEmail -ToAddress "[email protected]" `
        -GuestName $guest.DisplayName `
        -GuestMail $guest.Mail `
        -DaysInactive $guest.DaysSinceSignIn
    Write-Host "Notification sent for: $($guest.DisplayName)"
}

Removing Stale Guest Accounts

Once the review window has passed, you can disable or delete the accounts. A two-phase approach is safer: disable first, then hard-delete after a 14-day hold period, giving you a recovery window via the Entra ID recycle bin.

# Phase 1: Disable accounts flagged for removal
foreach ($guest in $stale) {
    Update-MgUser -UserId $guest.Id -AccountEnabled $false
    Write-Host "Disabled: $($guest.DisplayName)"
}

# Phase 2: Delete accounts that have been disabled for more than 14 days
# (Run this as a separate step after the hold period)
$disabledGuests = Get-MgUser -Filter "userType eq 'Guest' and accountEnabled eq false" `
    -All -Property Id, DisplayName, Mail, SignInActivity

foreach ($guest in $disabledGuests) {
    Remove-MgUser -UserId $guest.Id -Confirm:$false
    Write-Host "Deleted: $($guest.DisplayName)"
}

Scheduling with GitHub Actions

You can automate this script on a weekly schedule using GitHub Actions. Store your service principal credentials as repository secrets and use the following workflow.

name: Guest Lifecycle Management

on:
  schedule:
    - cron: '0 6 * * 1'   # Every Monday at 06:00 UTC
  workflow_dispatch:

jobs:
  cleanup:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Run Guest Lifecycle Script
        shell: pwsh
        env:
          ENTRA_TENANT_ID:     ${{ secrets.ENTRA_TENANT_ID }}
          ENTRA_CLIENT_ID:     ${{ secrets.ENTRA_CLIENT_ID }}
          ENTRA_CLIENT_SECRET: ${{ secrets.ENTRA_CLIENT_SECRET }}
        run: |
          Install-Module Microsoft.Graph -Force -Scope CurrentUser
          ./scripts/Invoke-GuestLifecycle.ps1

      - name: Upload report artifact
        uses: actions/upload-artifact@v4
        with:
          name: guest-report
          path: guest-report-*.csv

Putting It All Together

Automating guest user lifecycle management removes the manual overhead of periodic access reviews and keeps your tenant clean and compliant. The workflow covered here connects with a service principal, discovers all guests, flags stale accounts based on sign-in activity, notifies stakeholders, and finally disables then deletes in two safe phases.

Combined with Entra ID Access Reviews for owner-driven attestation, this PowerShell and Graph API approach gives you a solid, auditable foundation for external identity governance. Run the discovery script first in report-only mode, review the output, then enable the disable and delete phases incrementally so you can validate the results at each step before proceeding.