A Proliferation of Guest User Accounts Within Tenants
Updated July 19. 2023
Azure Active Directory Guest User Accounts are terrifically useful in terms of allowing people outside your Microsoft 365 tenant to access resources inside the tenant. Applications like Teams, SharePoint Online, Planner, and Outlook Groups use the Azure B2B Collaboration framework to create new guest accounts to share information, whether it’s the membership of a team or access to a shared document or folder.
Guest accounts might be needed for to share just one document. Old guest accounts can accumulate over time. A regular spring cleaning is needed to ensure that you detect old guest accounts that are possibly no longer needed.
The Problem of Deciding When to Leave
As always, the problem is to decide when a guest account should be removed. Left by themselves, guest accounts will remain in the tenant directory because neither Office 365 nor Azure Active Directory include an automated method to clean up guests past their best-by date. One approach is to review guest accounts that are older than a certain age and look for evidence to indicate if they should be removed.
For example, you might decide that membership of multiple Microsoft 365 groups (aka Office 365 groups) is sufficient reason to keep guest accounts. The logic here is that these memberships give people access to Teams (conversations), Outlook Groups (conversations delivered via email), and Planner (group tasks). Therefore, if we write a script to scan for guest accounts older than x days and then check if these accounts are members of groups, we should have some evidence upon which to base a decision to remove or keep.
PowerShell Script to Highlight Old Guest Accounts and their Group Membership
The script below does the following:
Connects to the Graph with the Connect-MgGraph cmdlet.
Finds all guest accounts in the tenant using the Get-MgUser cmdlet.
Checks each guest to discover its age using the account creation date.
If the guest account is older than 365 days, we look for its group membership by running the Get-MgUserMemberOf cmdlet and report the display names of any groups found.
Checks the last sign-in activity for the account. This could be an important indicator that the account is active.
Writes the discovered information out to an array.
After all guest accounts are processed, the script writes the contents of the array containing information about old guest accounts to a CSV file.
Some example code is shown below. The latest version of the script is available on GitHub and is the version which you should download and use. The latest version runs with the Microsoft Graph PowerShell V2 cmdlets.
Remember that you might want to update the code to add error handling and do whatever testing is necessary before running the script against your production tenant.
# Script to find Old Guest Accounts in an Office 365 Tenant that are older than 365 days and the groups they belong to
# Find guest accounts
Write-Host "Finding Guest Accounts..."
[Array]$GuestUsers = Get-MgUser -Filter "userType eq 'Guest'" -All -Property Id, displayName, userPrincipalName, createdDateTime, signInActivity `
| Sort-Object displayName
$i = 0; $Report = [System.Collections.Generic.List[Object]]::new()
# Loop through the guest accounts looking for old accounts
CLS
ForEach ($Guest in $GuestUsers) {
# Check the age of the guest account, and if it's over the threshold for days, report it
$AccountAge = ($Guest.CreatedDateTime | New-TimeSpan).Days
$i++
If ($AccountAge -gt $AgeThreshold) {
$ProgressBar = "Processing Guest " + $Guest.DisplayName + " " + $AccountAge + " days old " + " (" + $i + " of " + $GuestUsers.Count + ")"
Write-Progress -Activity "Checking Guest Account Information" -Status $ProgressBar -PercentComplete ($i/$GuestUsers.Count*100)
$StaleGuests++
$GroupNames = $Null
# Find what Microsoft 365 Groups the guest belongs to... if any
[array]$GuestGroups = (Get-MgUserMemberOf -UserId $Guest.Id).additionalProperties.displayName
If ($GuestGroups) {
$GroupNames = $GuestGroups -Join ", "
} Else {
$GroupNames = "None"
}
# Find the last sign-in date for the guest account, which might indicate how active the account is
$UserLastLogonDate = $Null
$UserLastLogonDate = $Guest.SignInActivity.LastSignInDateTime
If ($Null -ne $UserLastLogonDate) {
$UserLastLogonDate = Get-Date ($UserLastLogonDate) -format g
} Else {
$UserLastLogonDate = "No recent sign in records found"
}
$ReportLine = [PSCustomObject][Ordered]@{
UPN = $Guest.UserPrincipalName
Name = $Guest.DisplayName
Age = $AccountAge
"Account created" = $Guest.createdDateTime
"Last sign in" = $UserLastLogonDate
Groups = $GroupNames }
$Report.Add($ReportLine) }
} # End Foreach Guest
Sample Report File
The output CSV file is shown (somewhat obscured to protect the names of the guilty) in Figure 1. Any guest that isn’t a member of at least one Microsoft 365 group is a potential delete target. As you can see from the created column, it’s easy for old and stale guest accounts to linger on unless you clean them up from time to time.
Figure 1: Reporting old guest user accounts and their group membership
Details of a more comprehensive report of membership of Microsoft 365 Groups including both tenant and guest members and summary details are explained in this article.
We have lots of other PowerShell examples of how to manage Azure Active Directory guest users and other Office 365 objects in the Office 365 for IT Pros eBook.
Access reviews are indeed a good way to push responsibility down to group owners. However, they require Azure AD Premium licenses and don’t give tenants an overview of what’s happening with guests across all groups.
I must be missing some simple step. Powershell is dumping this error message for each user found. Can someone help?
Get-Recipient : The term ‘Get-Recipient’ is not recognized as the name of a cmdlet, function, script file, or operable program. Check the spelling of the name, or if a path was included,
verify that the path is correct and try again.
At line:13 char:14
+ $DN = (Get-Recipient -Identity $Guest.UserPrincipalName).Distin …
+ ~~~~~~~~~~~~~
+ CategoryInfo : ObjectNotFound: (Get-Recipient:String) [], CommandNotFoundException
+ FullyQualifiedErrorId : CommandNotFoundException
Has the RefreshTokensValidFromDateTime always been the creation date I have the feeling that this changed? https://www.undocumented-features.com/2018/06/22/how-to-find-staleish-azure-b2b-guest-accounts I was also interested to see that there is a creation date in the ExtensionProperty (Get-AzureADUser -ObjectId josh@domain.com).ExtensionProperty.createdDateTime. Might be in the previewonly would need to check. The onPremisesDistinguishedName is also great add we can do alot with this value in a multi forest environment.
Thanks Tony. I took inspiration from your blog post and script and created a end to end work flow based on the new signin logs data in Graph. I have found that this has broader scope, faster and more reliable than Search-UnifiedAuditLog. I gave you a little credit in the script as you started the conversation 🙂 I hope that’s ok? Let me know. JB https://github.com/JBines/Remove-StaleGuests
Loading...
Very cool! I love the way people take ideas and develop them in PowerShell…
Loading...
BTW, one thing that’s good about Search-UnifiedAuditLog is that it delivers real activity data. The sign in information is interesting, but a client can sign in and stay signed in and active for quite a while without their sign in date being updated (because they haven’t been forced to sign in again). So the sign in data is accurate but misleading as an indicator of activity.
Loading...
Where the session timeout is I’m not sure for the auditlogs is it 8 hours or 6 days ( Keep me signed in) it would be good to test and confirm the results. Sounds like a good topic for a new blog post 🙂
Loading...
Audit log data should be available within 15 minutes of a SharePoint or Exchange event. It can take longer for events from other workloads…
This works a treat and I’ve now a report of several 100 guests and only two of them are members of groups. It could be the groups they used to be members of no longer exist. We’re not using guest access at the moment – as in inviting new guests to Groups/Teams. We disabled it for a lengthy period of time and during that time guests have been added to the directory… erm? Is this a quirk of Onedrive external sharing when used with ‘share with specific people’ and it adds them to the directory? Thanks for any pointers.
Hi – Im running into an issue with the script, it seems to run fine until it hit a certain guest account which stops the script. Any thoughts on how to resolve? Thank you for sharing btw!
Processing O’Malley, Matthew
Processing O’Neal, Linda
Cannot bind parameter ‘Filter’ to the target. Exception setting “Filter”: “Invalid filter syntax. For a description of
the filter parameter syntax see the command help.
“Members -eq ‘CN=linda.o’neal_xxxxxx.com\#EXT\#,OU=xxxxxx.onmicrosoft.com,OU=Microsoft Exchange Hosted
Organizations,DC=NAMPRXXXXXX,DC=prod,DC=outlook,DC=com'” at position 25.”
At C:\Users\xxxxx\AppData\Local\Temp\2\tmp_q5kd52cd.rox\tmp_q5kd52cd.rox.psm1:30612 char:9
+ $steppablePipeline.End()
+ ~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : WriteError: (:) [Get-Recipient], ParameterBindingException
+ FullyQualifiedErrorId : ParameterBindingFailed,Microsoft.Exchange.Management.RecipientTasks.GetRecipient
I have posted a new version of the script to GitHub which processes guest accounts with apostrophes in their distinguished name. Please try that and let me know if you have any other problems.
Is it possible to pull their last login time, maybe set it to filter for 365 as well. That way we know it’s truely stale and can be removed. We have projects that go for 3 years, I feel the last login time would be crucial for our scenario.
Yes. There’s an API to grab the last signed in date for an account. It would be easy enough to include a check. I show how to do this in https://petri.com/azuread-signin-powershell.
Seeing that there’s nothing better to do on a Friday evening, I updated the script to add the check for the last Azure AD sign in activity. The updated script is available from GitHub.
I modified line 41 to include a couple of other attributes (Select DisplayName, ExternalDirectoryObjectId, ManagedBy, Notes, PrimarySmtpAddress). Curious if it’s possible to get the primary email or UPN of that ManagedBy attribute? I was thinking maybe an expression might work by calling on Get-Recipient again but not sure how to set that correctly.
I believe ManagedBy is returned as an array. If my recollection is correct, you could go through the array and use Get-Recipient or Get-ExoMailbox to return whatever properties you want for each item.
{"id":null,"mode":"button","open_style":"in_modal","currency_code":"EUR","currency_symbol":"\u20ac","currency_type":"decimal","blank_flag_url":"https:\/\/office365itpros.com\/wp-content\/plugins\/tip-jar-wp\/\/assets\/images\/flags\/blank.gif","flag_sprite_url":"https:\/\/office365itpros.com\/wp-content\/plugins\/tip-jar-wp\/\/assets\/images\/flags\/flags.png","default_amount":100,"top_media_type":"featured_image","featured_image_url":"https:\/\/office365itpros.com\/wp-content\/uploads\/2022\/11\/cover-141x200.jpg","featured_embed":"","header_media":null,"file_download_attachment_data":null,"recurring_options_enabled":true,"recurring_options":{"never":{"selected":true,"after_output":"One time only"},"weekly":{"selected":false,"after_output":"Every week"},"monthly":{"selected":false,"after_output":"Every month"},"yearly":{"selected":false,"after_output":"Every year"}},"strings":{"current_user_email":"","current_user_name":"","link_text":"Virtual Tip Jar","complete_payment_button_error_text":"Check info and try again","payment_verb":"Pay","payment_request_label":"Office 365 for IT Pros","form_has_an_error":"Please check and fix the errors above","general_server_error":"Something isn't working right at the moment. Please try again.","form_title":"Office 365 for IT Pros","form_subtitle":null,"currency_search_text":"Country or Currency here","other_payment_option":"Other payment option","manage_payments_button_text":"Manage your payments","thank_you_message":"Thank you for supporting the work of Office 365 for IT Pros!","payment_confirmation_title":"Office 365 for IT Pros","receipt_title":"Your Receipt","print_receipt":"Print Receipt","email_receipt":"Email Receipt","email_receipt_sending":"Sending receipt...","email_receipt_success":"Email receipt successfully sent","email_receipt_failed":"Email receipt failed to send. Please try again.","receipt_payee":"Paid to","receipt_statement_descriptor":"This will show up on your statement as","receipt_date":"Date","receipt_transaction_id":"Transaction ID","receipt_transaction_amount":"Amount","refund_payer":"Refund from","login":"Log in to manage your payments","manage_payments":"Manage Payments","transactions_title":"Your Transactions","transaction_title":"Transaction Receipt","transaction_period":"Plan Period","arrangements_title":"Your Plans","arrangement_title":"Manage Plan","arrangement_details":"Plan Details","arrangement_id_title":"Plan ID","arrangement_payment_method_title":"Payment Method","arrangement_amount_title":"Plan Amount","arrangement_renewal_title":"Next renewal date","arrangement_action_cancel":"Cancel Plan","arrangement_action_cant_cancel":"Cancelling is currently not available.","arrangement_action_cancel_double":"Are you sure you'd like to cancel?","arrangement_cancelling":"Cancelling Plan...","arrangement_cancelled":"Plan Cancelled","arrangement_failed_to_cancel":"Failed to cancel plan","back_to_plans":"\u2190 Back to Plans","update_payment_method_verb":"Update","sca_auth_description":"Your have a pending renewal payment which requires authorization.","sca_auth_verb":"Authorize renewal payment","sca_authing_verb":"Authorizing payment","sca_authed_verb":"Payment successfully authorized!","sca_auth_failed":"Unable to authorize! Please try again.","login_button_text":"Log in","login_form_has_an_error":"Please check and fix the errors above","uppercase_search":"Search","lowercase_search":"search","uppercase_page":"Page","lowercase_page":"page","uppercase_items":"Items","lowercase_items":"items","uppercase_per":"Per","lowercase_per":"per","uppercase_of":"Of","lowercase_of":"of","back":"Back to plans","zip_code_placeholder":"Zip\/Postal Code","download_file_button_text":"Download File","input_field_instructions":{"tip_amount":{"placeholder_text":"How much would you like to tip?","initial":{"instruction_type":"normal","instruction_message":"How much would you like to tip? Choose any currency."},"empty":{"instruction_type":"error","instruction_message":"How much would you like to tip? Choose any currency."},"invalid_curency":{"instruction_type":"error","instruction_message":"Please choose a valid currency."}},"recurring":{"placeholder_text":"Recurring","initial":{"instruction_type":"normal","instruction_message":"How often would you like to give this?"},"success":{"instruction_type":"success","instruction_message":"How often would you like to give this?"},"empty":{"instruction_type":"error","instruction_message":"How often would you like to give this?"}},"name":{"placeholder_text":"Name on Credit Card","initial":{"instruction_type":"normal","instruction_message":"Enter the name on your card."},"success":{"instruction_type":"success","instruction_message":"Enter the name on your card."},"empty":{"instruction_type":"error","instruction_message":"Please enter the name on your card."}},"privacy_policy":{"terms_title":"Terms and conditions","terms_body":null,"terms_show_text":"View Terms","terms_hide_text":"Hide Terms","initial":{"instruction_type":"normal","instruction_message":"I agree to the terms."},"unchecked":{"instruction_type":"error","instruction_message":"Please agree to the terms."},"checked":{"instruction_type":"success","instruction_message":"I agree to the terms."}},"email":{"placeholder_text":"Your email address","initial":{"instruction_type":"normal","instruction_message":"Enter your email address"},"success":{"instruction_type":"success","instruction_message":"Enter your email address"},"blank":{"instruction_type":"error","instruction_message":"Enter your email address"},"not_an_email_address":{"instruction_type":"error","instruction_message":"Make sure you have entered a valid email address"}},"note_with_tip":{"placeholder_text":"Your note here...","initial":{"instruction_type":"normal","instruction_message":"Attach a note to your tip (optional)"},"empty":{"instruction_type":"normal","instruction_message":"Attach a note to your tip (optional)"},"not_empty_initial":{"instruction_type":"normal","instruction_message":"Attach a note to your tip (optional)"},"saving":{"instruction_type":"normal","instruction_message":"Saving note..."},"success":{"instruction_type":"success","instruction_message":"Note successfully saved!"},"error":{"instruction_type":"error","instruction_message":"Unable to save note note at this time. Please try again."}},"email_for_login_code":{"placeholder_text":"Your email address","initial":{"instruction_type":"normal","instruction_message":"Enter your email to log in."},"success":{"instruction_type":"success","instruction_message":"Enter your email to log in."},"blank":{"instruction_type":"error","instruction_message":"Enter your email to log in."},"empty":{"instruction_type":"error","instruction_message":"Enter your email to log in."}},"login_code":{"initial":{"instruction_type":"normal","instruction_message":"Check your email and enter the login code."},"success":{"instruction_type":"success","instruction_message":"Check your email and enter the login code."},"blank":{"instruction_type":"error","instruction_message":"Check your email and enter the login code."},"empty":{"instruction_type":"error","instruction_message":"Check your email and enter the login code."}},"stripe_all_in_one":{"initial":{"instruction_type":"normal","instruction_message":"Enter your credit card details here."},"empty":{"instruction_type":"error","instruction_message":"Enter your credit card details here."},"success":{"instruction_type":"normal","instruction_message":"Enter your credit card details here."},"invalid_number":{"instruction_type":"error","instruction_message":"The card number is not a valid credit card number."},"invalid_expiry_month":{"instruction_type":"error","instruction_message":"The card's expiration month is invalid."},"invalid_expiry_year":{"instruction_type":"error","instruction_message":"The card's expiration year is invalid."},"invalid_cvc":{"instruction_type":"error","instruction_message":"The card's security code is invalid."},"incorrect_number":{"instruction_type":"error","instruction_message":"The card number is incorrect."},"incomplete_number":{"instruction_type":"error","instruction_message":"The card number is incomplete."},"incomplete_cvc":{"instruction_type":"error","instruction_message":"The card's security code is incomplete."},"incomplete_expiry":{"instruction_type":"error","instruction_message":"The card's expiration date is incomplete."},"incomplete_zip":{"instruction_type":"error","instruction_message":"The card's zip code is incomplete."},"expired_card":{"instruction_type":"error","instruction_message":"The card has expired."},"incorrect_cvc":{"instruction_type":"error","instruction_message":"The card's security code is incorrect."},"incorrect_zip":{"instruction_type":"error","instruction_message":"The card's zip code failed validation."},"invalid_expiry_year_past":{"instruction_type":"error","instruction_message":"The card's expiration year is in the past"},"card_declined":{"instruction_type":"error","instruction_message":"The card was declined."},"missing":{"instruction_type":"error","instruction_message":"There is no card on a customer that is being charged."},"processing_error":{"instruction_type":"error","instruction_message":"An error occurred while processing the card."},"invalid_request_error":{"instruction_type":"error","instruction_message":"Unable to process this payment, please try again or use alternative method."},"invalid_sofort_country":{"instruction_type":"error","instruction_message":"The billing country is not accepted by SOFORT. Please try another country."}}}},"fetched_oembed_html":false}
Another approach is to use the Azure AD Access Review process which delegates this responsibility to the Group Owners, see https://docs.microsoft.com/en-us/azure/active-directory/governance/access-reviews-overview
Access reviews are indeed a good way to push responsibility down to group owners. However, they require Azure AD Premium licenses and don’t give tenants an overview of what’s happening with guests across all groups.
I must be missing some simple step. Powershell is dumping this error message for each user found. Can someone help?
Get-Recipient : The term ‘Get-Recipient’ is not recognized as the name of a cmdlet, function, script file, or operable program. Check the spelling of the name, or if a path was included,
verify that the path is correct and try again.
At line:13 char:14
+ $DN = (Get-Recipient -Identity $Guest.UserPrincipalName).Distin …
+ ~~~~~~~~~~~~~
+ CategoryInfo : ObjectNotFound: (Get-Recipient:String) [], CommandNotFoundException
+ FullyQualifiedErrorId : CommandNotFoundException
PowerShell is telling you that it can’t find the Get-Recipient cmdlet. This is most likely because you haven’t connected to Exchange Online.
Has the RefreshTokensValidFromDateTime always been the creation date I have the feeling that this changed? https://www.undocumented-features.com/2018/06/22/how-to-find-staleish-azure-b2b-guest-accounts I was also interested to see that there is a creation date in the ExtensionProperty (Get-AzureADUser -ObjectId josh@domain.com).ExtensionProperty.createdDateTime. Might be in the previewonly would need to check. The onPremisesDistinguishedName is also great add we can do alot with this value in a multi forest environment.
For guest accounts, I think the two values will be the same, but if you want to use createdDateTime in the script replace the line with:
$AADAccountAge = ((Get-AzureADUser -ObjectId $Guest.UserPrincipalName).ExtensionProperty.createdDateTime | New-TimeSpan).Days
Thanks Tony. I took inspiration from your blog post and script and created a end to end work flow based on the new signin logs data in Graph. I have found that this has broader scope, faster and more reliable than Search-UnifiedAuditLog. I gave you a little credit in the script as you started the conversation 🙂 I hope that’s ok? Let me know. JB https://github.com/JBines/Remove-StaleGuests
Very cool! I love the way people take ideas and develop them in PowerShell…
BTW, one thing that’s good about Search-UnifiedAuditLog is that it delivers real activity data. The sign in information is interesting, but a client can sign in and stay signed in and active for quite a while without their sign in date being updated (because they haven’t been forced to sign in again). So the sign in data is accurate but misleading as an indicator of activity.
Where the session timeout is I’m not sure for the auditlogs is it 8 hours or 6 days ( Keep me signed in) it would be good to test and confirm the results. Sounds like a good topic for a new blog post 🙂
Audit log data should be available within 15 minutes of a SharePoint or Exchange event. It can take longer for events from other workloads…
This works a treat and I’ve now a report of several 100 guests and only two of them are members of groups. It could be the groups they used to be members of no longer exist. We’re not using guest access at the moment – as in inviting new guests to Groups/Teams. We disabled it for a lengthy period of time and during that time guests have been added to the directory… erm? Is this a quirk of Onedrive external sharing when used with ‘share with specific people’ and it adds them to the directory? Thanks for any pointers.
Guest accounts are also created when people share documents from SharePoint Online and OneDrive for Business with external users. You could try using this report https://office365itpros.com/2019/10/22/onedrive-for-business-external-sharing-report/ to check on OneDrive sharing or use the script in our repository https://office365itpros.com/office-365-github-repository/ to track guests added through sharing.
Very helpful thanks. I’ll go check them out
Hi – Im running into an issue with the script, it seems to run fine until it hit a certain guest account which stops the script. Any thoughts on how to resolve? Thank you for sharing btw!
Processing O’Malley, Matthew
Processing O’Neal, Linda
Cannot bind parameter ‘Filter’ to the target. Exception setting “Filter”: “Invalid filter syntax. For a description of
the filter parameter syntax see the command help.
“Members -eq ‘CN=linda.o’neal_xxxxxx.com\#EXT\#,OU=xxxxxx.onmicrosoft.com,OU=Microsoft Exchange Hosted
Organizations,DC=NAMPRXXXXXX,DC=prod,DC=outlook,DC=com'” at position 25.”
At C:\Users\xxxxx\AppData\Local\Temp\2\tmp_q5kd52cd.rox\tmp_q5kd52cd.rox.psm1:30612 char:9
+ $steppablePipeline.End()
+ ~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : WriteError: (:) [Get-Recipient], ParameterBindingException
+ FullyQualifiedErrorId : ParameterBindingFailed,Microsoft.Exchange.Management.RecipientTasks.GetRecipient
Looks as if the script is having problems with the CN attribute of the distinguished name because it contains an apostrophe. See https://www.michev.info/Blog/Post/737/using-filters-against-objects-containing-special-characters
I have posted a new version of the script to GitHub which processes guest accounts with apostrophes in their distinguished name. Please try that and let me know if you have any other problems.
Is it possible to pull their last login time, maybe set it to filter for 365 as well. That way we know it’s truely stale and can be removed. We have projects that go for 3 years, I feel the last login time would be crucial for our scenario.
Yes. There’s an API to grab the last signed in date for an account. It would be easy enough to include a check. I show how to do this in https://petri.com/azuread-signin-powershell.
Seeing that there’s nothing better to do on a Friday evening, I updated the script to add the check for the last Azure AD sign in activity. The updated script is available from GitHub.
Hello Tony,
Thanks for your great script but I did get a “too many requests” message on this command: Get-AzureADAuditSignInLogs.
How many accounts are you trying to process? Maybe the Graph or the Graph SDK for PowerShell (see example in https://practical365.com/using-microsoft-sdk-powershell-report-azure-ad-account-sign-ins/) might be better when large number of accounts must be processed.
I also encountered the ‘too many requests’ but was able to workaround this by adding “Start-Sleep -Millseconds 500” at the end of the ForEach loop.
I modified line 41 to include a couple of other attributes (Select DisplayName, ExternalDirectoryObjectId, ManagedBy, Notes, PrimarySmtpAddress). Curious if it’s possible to get the primary email or UPN of that ManagedBy attribute? I was thinking maybe an expression might work by calling on Get-Recipient again but not sure how to set that correctly.
I believe ManagedBy is returned as an array. If my recollection is correct, you could go through the array and use Get-Recipient or Get-ExoMailbox to return whatever properties you want for each item.