How to Purge Guest Accounts with Unredeemed Invitations from Entra ID

Use PowerShell to Find and Remove Entra ID Guest Accounts Who Don’t Want to Join Your Party

Updated 5 September 2023

A January 30 post by Microsoft’s Jef Kazimer about using Azure Automation with Managed Identities to remove unredeemed guests from Entra ID (Azure AD) promised to be a good read. Jef is a Principal Program Manager in the Microsoft Entra organization. Apart from using Azure Automation (something that every tenant administrator should master), highlighting the Microsoft Graph PowerShell SDK V2.0 (currently in early preview) gave me another reason to read the article.

I have expressed some concerns about Microsoft’s plans for the V2.0 of the Microsoft Graph PowerShell SDK. Leaving those concerns aside, it’s always good to learn how others approach a problem, especially as I’ve recently covered similar ground in terms of how to decide to remove guest accounts using the SDK. The differences between the two methods of reviewing guest accounts is that Jef looks for instances where guest accounts never went through the invitation redemption process to fully validate their accounts. On the other hand, my script looks at how long it’s been since a guest signed into the tenant and the number of groups the account is a member of to determine “staleness.” Let’s consider how to review guest accounts based on unredeemed invitations.

Outlining the Process

On paper, the steps involved to find and remove guest accounts with unredeemed invitations are straightforward:

  • Find guest accounts that have not redeemed the invitations received to join the tenant.
  • Remove the accounts from Entra ID.

Jef’s article suggests that this should be a regular process executed by an Azure Automation job using a managed identity to sign into the Graph and run the necessary PowerShell commands. I agree and think this is a good way to make sure to clear out unwanted guest accounts periodically.

Where I disagree is the detail of how to find the guests. Let’s discuss.

The Need for Administrative Units

Jef uses a dynamic administrative unit (currently a preview feature) to manage guest accounts. While it’s certainly convenient to create a dynamic administrative unit and assign the user management role for the administrative unit to the managed identity, this approach is optional and creates a potential requirement for Entra ID Premium P1 licenses. If your organization has those licenses, using a dynamic administrative unit offers the advantage of reducing the scope for the managed identity to process Entra ID accounts.

In some organizations, using administrative units (both the standard and dynamic variants) could be overkill because user management is a task performed by one or two administrators. In larger organizations, granularity in user management can be a desirable aspect, which is why administrative units exist.

Finding Entra ID Guest Accounts with Unredeemed Invitations

The first step is to find the target set of guest accounts. The simplest way is to run the Get-MgUser cmdlet and filter accounts to look for guests:

Connect-MgGraph -Scope Directory.ReadWrite.All
Select-MgProfile Beta
[array]$Guests = Get-MgUser -Filter "userType eq 'Guest'" -All

The guest accounts we want are those that have the ExternalUserState property set to “PendingAcceptance.” In other words, Entra ID issued an invitation to the guest’s email address, but the guest never followed up to redeem their invitation. This amended call to Get-MgUser fetches the set of guest accounts with unredeemed invitations:

[array]$Guests = Get-MgUser -Filter "userType eq 'Guest' and ExternalUserState eq 'PendingAcceptance'" -All

Jef’s version uses the Get-MsIDUnredeemedInviteUser cmdlet from the MSIdentityTools module to find guest accounts with unredeemed invitations. It’s certainly worth considering using the MSIdentityTools module to manage Entra ID, but it’s also worth understanding how to do a job with the basic tools, which is what I do here.

Determining the Age of an Unredeemed Invitation

It would be unwise to remove any Entra ID guest accounts without giving their owners a little time to respond. Taking vacation periods into account, 45 days seem sufficient time for anyone to make their minds up. The loop to remove unredeemed guest accounts needs to check how long it’s been since Entra ID issued the invitation and only process the accounts that exceed the age threshold.

Our script can check when Entra ID created an invitation by checking the ExternalUserStateChangeDateTime property, which holds a timestamp for the last time the state of the account changed. The only state change for the accounts we’re interested in occurred when Entra ID created the invitations to join the tenant, so we can use the property to measure how long it’s been since a guest received their invitation.

This code shows how to loop through the set of guests with unredeemed invitations, check if their invitation is more than 45 days old, and remove the account that satisfy the test. To keep a record of what it does, the script logs the deletions.

[datetime]$Deadline = (Get-Date).AddDays(-45)
$Report = [System.Collections.Generic.List[Object]]::new()
ForEach ($Guest in $Guests) {
  # Check Date
  [datetime]$InvitationSent = $Guest.ExternalUserStateChangeDateTime
  If ($InvitationSent -le $Deadline) {
     $DateInvitation = Get-Date($InvitationSent) -format g
     $DaysOld = (New-TimeSpan ($InvitationSent)).Days
     Try { 
        Remove-MgUser -UserId $Guest.Id
        $ReportLine = [PSCustomObject][Ordered]@{  
          Date        = Get-Date
          User        = $Guest.displayName
          UPN         = $Guest.UserPrincipalName
          Invited     = $DateInvitation
          "Days old"  = $DaysOld }
        $Report.Add($ReportLine)
      }
      Catch {
        Write-Error $_
      }
   } #End if
} #End Foreach Guest
Write-Host "Guest Accounts removed for" ($Report.User -Join ", ")

Figure 1 shows some data from the report generated for the deletions. In an Azure Automation scenario, you could create a report in SharePoint Online, send email to administrators, or post a message to a Teams channel to advise people about the removed accounts.

Old Entra ID guest accounts with unredeemed invitations
Figure 1: Old guest accounts with unredeemed invitations

Caveats Before Removing Entra ID Guest Accounts

The code works and stale guest account disappear to the Entra ID recycle bin. However, the danger exists that some of the accounts might be in active use. Take guest accounts created to represent the email addresses of Teams channels. These email addresses represent a connector to import messages into Teams channels. No one can sign into these non-existent mailboxes so no one  will ever redeem the guest invitations. However, the mail user objects created by Exchange Online for these guest accounts allow them to be included in distribution lists, added to address lists, and so on.

Another example is when a guest joins an Outlook group (a Microsoft 365 group whose membership communicates via email). Guest members of these groups do not need to redeem their invitation unless they intend to sign into the tenant to access Teams or SharePoint Online or another application that supports Azure B2B Collaboration. If you remove these guest accounts based on their invitation redemption status, some important email-based communication might fail, and that would be a bad thing.

One way around the issue is to mark Entra ID guest accounts used for these purposes by writing a value into an appropriate property. For instance, set the department to EMAIL. Here’s how to mark the set of guest accounts used to route email to Teams channels:

[array]$MailGuests = $Guests | Where-Object {$_.Mail -Like "*teams.ms*"}  
ForEach ($MG in $MailGuests) { Update-MgUser -UserId $MG.Id -Department "EMAIL" }

And here’s how to mark the guest members for an Outlook group using cmdlets from the Exchange Online management module:

[array]$Members = Get-UnifiedGroupLinks -Identity 'Exchange Grumpy Alumni' -LinkType Member
ForEach ($Member in $Members) { 
  If ($Member.RecipientType -eq "MailUser")  { Set-User -Identity $Member.Name -Department "EMAIL" -Confirm:$False }
}

After marking some guest accounts as exceptions, we can find the set of guest accounts to process with:

[array]$Guests = Get-MgUser -Filter "userType eq 'Guest'" -All | Where-Object {$_.ExternalUserState -eq "PendingAcceptance" -and $_.Department -ne "EMAIL"}

All of this goes to prove that setting out to automate what appears to be a straightforward administrative task might lead to unforeseen consequences if you don’t think through the different ways applications use the objects.

Using SDK V2.0

Coming back to using V2.0 of the Microsoft Graph PowerShell SDK, nothing done so far needs V2.0. The only mention of a V2.0-specific feature is the support for a managed identity when connecting to the Graph. The code used to connect is:

Connect-MgGraph -Identity

A one-liner is certainly convenient, but it’s possible to connect to a managed identity with the Graph SDK with code that is just a little more complicated. Here’s what I do:

Connect-AzAccount -Identity
$AccessToken = Get-AzAccessToken -ResourceUrl "https://graph.microsoft.com"
Connect-MgGraph -AccessToken $AccessToken.Token

Going from three lines to one is probably not a huge benefit!


So much change, all the time. It’s a challenge to stay abreast of all the updates Microsoft makes across Office 365. Subscribe to the Office 365 for IT Pros eBook to receive monthly insights into what happens, why it happens, and what new features and capabilities mean for your tenant.

3 Replies to “How to Purge Guest Accounts with Unredeemed Invitations from Entra ID”

  1. Hi Tony, just checking your assertion that get-mguser can’t filter on externalUserState… I run this command regularly to give me a view of everyone that’s been invited, including timing… works like a champ. I’ve currently got version 1.9.4 installed. – Martin

    get-mguser -filter “externalUserState eq ‘accepted’ or externalUserState eq ‘pendingacceptance'” -all | select exter*,user*,*mail,mailnickname,othermails,comp*,@{n=’created’;e={[DateTime]$_.createdDateTime}}| sort-object externaluserstatechangedatetime -descending|ogv

    1. Hi Martin,

      Thanks for your comment. As it turns out, I made a mistake and pasted the wrong text into the article. Mea cupla maxima. My only plea is that I had some problems (I can’t say why now, it’s too long ago) and had moved on with the client-side filter, then went back and fixed whatever issue I had, but never transcribed the updated text into WordPress.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.