Entra / Microsoft 365 · Users & guests
Find inactive Entra ID guests with audit
Find inactive Entra ID guest accounts and report their recent audit log activity, optionally emailing the results.
Connect & set up
Run these once per session. All scopes are read-only unless the script makes changes.
Connect-MgGraph -NoWelcome -Scopes User.Read.All, AuditLog.Read.All, Mail.Send, Organization.Read.AllConnect-ExchangeOnline -ShowBanner:$false
Run it
The main script. Copy it, or download the .ps1 and run it from your console.
param([int] $LookbackDays = 365,[string] $StartDate = (Get-Date).AddDays(-$LookbackDays),[string] $EndDate = (Get-Date).AddDays(1),[string] $DestinationEmailAddress = "")$Interactive = $false# Determine if we're interactive or notIf ([Environment]::UserInteractive) {# We're running interactively...Write-Host "Script running interactively... connecting to the Graph" -ForegroundColor YellowConnect-MgGraph -NoWelcome -Scopes User.Read.All, AuditLog.Read.All, Mail.Send, Organization.Read.All$Interactive = $true[array]$Modules = Get-Module | Select-Object -ExpandProperty NameIf ("ExchangeOnlineManagement" -Notin $Modules) {Write-Host "Connecting to Exchange Online..." -ForegroundColor YellowConnect-ExchangeOnline -ShowBanner:$false}$MsgFrom = (Get-MgContext).Account} Else {# We're not, so likely in Azure AutomationWrite-Host "Running the script to identify the last app accessed by Users"Connect-MgGraph -Identity -NoWelcome$Tenant = Get-MgOrganization# Connect with a managed identity$TenantDomain = $Tenant.VerifiedDomains | Where-Object {$_.isDefault -eq $true} | Select-Object -ExpandProperty NameConnect-ExchangeOnline -ManagedIdentity -Organization $TenantDomain$CurrentFolder = (Get-Location).Path$MsgFrom = "no-reply@office365itpros.com"}# Check that we have the right permissions - in Azure Automation, we assume that the automation account has the right permissionsIf ($Interactive) {[string[]]$CurrentScopes = (Get-MgContext).Scopes[string[]]$RequiredScopes = @('AuditLog.Read.All','User.Read.All','Mail.Send', 'Organization.Read.All')$CheckScopes =[object[]][Linq.Enumerable]::Intersect($RequiredScopes,$CurrentScopes)If ($CheckScopes.Count -ne 4) {Write-Host ("To run this script, you need to connect to Microsoft Graph with the following scopes: {0}" -f $RequiredScopes) -ForegroundColor RedDisconnect-GraphBreak}}# Change this to the email address of the recipient of the report# Find information about sharing events so that we know when someone has been invited to the tenant or otherwise updated (like being added to a group)$ShareDateStart = (Get-Date).AddDays(-$LookbackDays)# SharePoint Sharing InvitationWrite-Output "Searching for SharePoint sharing invitations..."[array]$SharingRecords = Search-UnifiedAuditLog -StartDate $ShareDateStart -EndDate $EndDate -SessionCommand ReturnLargeSet -Formatted -ResultSize 5000 -Operations SharingInvitationCreated$SharingRecords = $SharingRecords | Sort-Object Identity -Unique$SharingData = [System.Collections.Generic.List[Object]]::new()ForEach ($Record in $SharingRecords) {$AuditData = $Record.AuditData | ConvertFrom-JsonIf ($AuditData.TargetUserOrGroupType -eq "Guest") {$Guest = $AuditData.TargetUserOrGroupName$InvitationSource = $AuditData.UserId$Item = [PSCustomObject]@{Guest = $Guest.toLower()InvitationSource = $InvitationSourceTimestamp = $Record.CreationDateApp = 'SharePoint Online'}$SharingData.Add($Item)}}# Guests added to Microsoft 365 GroupsWrite-Output "Searching for Microsoft 365 Groups invitations..."[array]$GroupData = Search-UnifiedAuditLog -StartDate $ShareDateStart -EndDate $EndDate -SessionCommand ReturnLargeSet -Formatted `-ResultSize 5000 -Operations 'Add member to group.'$GroupData = $GroupData | Sort-Object Identity -UniqueForEach ($Record in $GroupData) {$AuditData = $Record.AuditData | ConvertFrom-JsonIf ($AuditData.ObjectId -Like "*#EXT#*" -and $AuditData.UserId -notlike "*ServicePrincipal*") {$Guest = $AuditData.ObjectId$InvitationSource = $AuditData.UserId$Item = [PSCustomObject]@{Guest = $Guest.toLower()InvitationSource = $InvitationSourceTimestamp = $Record.CreationDateApp = 'Groups'}$SharingData.Add($Item)}}# Users invited from the Entra ID portalWrite-Output "Searching for Entra ID invitations..."[array]$InvitationData = Search-UnifiedAuditLog -StartDate $ShareDateStart -EndDate $EndDate -SessionCommand ReturnLargeSet -Formatted `-ResultSize 5000 -Operations 'Add user.'$InvitationData = $InvitationData | Sort-Object Identity -UniqueForEach ($Record in $InvitationData) {$AuditData = $Record.AuditData | ConvertFrom-JsonIf ($AuditData.ObjectId -Like "*#EXT#*" -and $AuditData.UserId -notlike "*ServicePrincipal*") {$Guest = $AuditData.ObjectId$InvitationSource = $AuditData.UserId$Item = [PSCustomObject]@{Guest = $Guest.toLower()InvitationSource = $InvitationSourceTimestamp = $Record.CreationDateApp = 'Entra ID'}$SharingData.Add($Item)}}$SharingData = $SharingData | Sort-Object {$_.TimeStamp -as [datetime]} -Descending[datetime]$StartProcessing = Get-Date# Define the audit records used to figure out the important events to indicate what guests have been doing[array]$Operations = "FileAccessed","FileModified","FileUploaded","FileDeleted","FileDownloaded","MessageSent", "ReactedToMessage",`"MessageRead", "MessageDeleted", "TaskCompleted", "TaskRead", "TaskAssigned", "SensitivityLabeledFileOpened", "TeamsSessionStarted", "UserLoggedIn", "SignInEvent"# Find all guests - a complex query is used to sort the retrieved resultsWrite-Output "Retrieving guest accounts..."[array]$Guests = Get-MgUser -Filter "usertype eq 'Guest'" -PageSize 500 -All `-Property DisplayName,UserPrincipalName,SignInActivity,Mail,Sponsors,Id,CreatedDateTime,AccountEnabled,EmployeeLeaveDateTime -ExpandProperty Sponsors `| Sort-Object displayNameIf ($Guests.Count -eq 0) {Write-Host "No guest users found."break} Else {Write-Host ("Found {0} guest users." -f $Guests.Count)}[int]$i = 0$Report = [System.Collections.Generic.List[Object]]::new()ForEach ($Guest in $Guests) {$i++$DaysSinceLastSignIn = $null; $DaysSinceLastSuccessfulSignIn = $null$GuestStatus = "Inactive"$TopActivities = $nullWrite-Host "Processing guest user $($Guest.DisplayName) <$($Guest.Mail)> ($($i)/$($Guests.Count))" -ForegroundColor Cyan[array]$Records = Search-UnifiedAuditLog -StartDate $StartDate -EndDate $EndDate -UserIds $Guest.UserPrincipalName `-SessionCommand ReturnLargeSet -Formatted -ResultSize 5000 -Operations $OperationsIf ($Records.Count -eq 0) {Write-Host "No audit records found for guest user" -ForegroundColor Red$LastActivity = $null} Else {# Remove duplicate records$Records = $Records | Sort-Object Identity -UniqueWrite-Host ("Found {0} audit records for guest user" -f $Records.Count) -ForegroundColor Yellow$LastActivity = $Records | Sort-Object {$_.CreationDate -as [datetime]} -Descending | Select-Object -First 1[array]$GuestActivity = $Records | Group-Object Operations -NoElement | Sort-Object Count -Desc | Select-Object -First 3$TopActivities = ($GuestActivity | ForEach-Object { "$($_.Name) ($($_.Count))" }) -join ", "$GuestStatus = "Active"}# Can we find when the guest was invited to the tenant?[array]$Invitation = $SharingData | Where-Object { $_.Guest -eq $Guest.UserPrincipalName.tolower() } | `Sort-Object {$_.TimeStamp -as [datetime]} -Descending | Select-Object -First 1If ($Invitation) {$InvitedTimeStamp = Get-Date $Invitation.Timestamp -Format 'dd-MMMM-yyyy HH:mm'$InvitedSource = $Invitation.InvitationSource} Else {$InvitedTimeStamp = $null$InvitedSource = $null}If (!([string]::IsNullOrWhiteSpace($Guest.signInActivity.lastSuccessfulSignInDateTime))) {[datetime]$LastSuccessfulSignIn = $Guest.signInActivity.lastSuccessfulSignInDateTime$DaysSinceLastSuccessfulSignIn = (New-TimeSpan $LastSuccessfulSignIn).Days}If (!([string]::IsNullOrWhiteSpace($Guest.signInActivity.lastSignInDateTime))) {[datetime]$LastSignIn = $Guest.signInActivity.lastSignInDateTime$DaysSinceLastSignIn = (New-TimeSpan $LastSignIn).Days}# Is there a photo for the guest?$Status = Get-MgUserPhoto -UserId $Guest.Id -ErrorAction SilentlyContinueIf ($Status) {$HasPhoto = $true} Else {$HasPhoto = $false}# Is the guest a member of any groups?$MemberOf = Get-MgUserMemberOf -UserId $Guest.Id | Where-Object {$_.AdditionalProperties.'@odata.type' -eq '#microsoft.graph.group'} -ErrorAction SilentlyContinueIf ($MemberOf) {$GroupsCount = $MemberOf.Count$GroupsNames = $MemberOf.additionalProperties.displayName -join "; "} Else {$GroupsCount = 0$GroupsNames = $null}# Is the guest account disabled or is the employee leave date time property populatedIf ($Guest.AccountEnabled -eq $false -or $null -ne $Guest.EmployeeLeaveDateTime) {$GuestStatus = "Inactive"}$ReportItem = [PSCustomObject]@{Guest = $Guest.DisplayNameUserPrincipalName = $Guest.UserPrincipalNameEmail = $Guest.MailSponsors = ($Guest.Sponsors | ForEach-Object { Get-MgUser -UserId $_.Id | Select-Object -ExpandProperty DisplayName }) -join "; "'Creation Date' = Get-Date $Guest.CreatedDateTime -format 'dd-MMMM-yyyy HH:mm''Days since creation' = (New-TimeSpan $Guest.CreatedDateTime).Days'Date Last Audit Activity' = If ($LastActivity) { Get-Date $LastActivity.CreationDate -format 'dd-MMMM-yyyy HH:mm'} else { $null }'Last Audit Activity' = If ($LastActivity) { $LastActivity.Operations } else { $null }'Number of Audit Activities' = $Records.Count'Top 3 activities' = $TopActivities'Last administrator action' = $InvitedTimeStamp'Administrator' = $InvitedSource'Last Signin' = Get-Date ($LastSignIn) -format 'dd-MMMM-yyyy HH:mm''Days since last signin' = $DaysSinceLastSignIn'Date of last successful signin'= Get-Date ($LastSuccessfulSignIn) -format 'dd-MMMM-yyyy HH:mm''Days since last successful signin' = $DaysSinceLastSuccessfulSigninEmailDomain = ($Guest.Mail -split "@")[1]HasPhoto = $HasPhoto'# of groups guest is member of'= $GroupsCount'Groups guest is member of' = $GroupsNamesId = $Guest.IdAccountEnabled = $Guest.AccountEnabled'Employee Leave Date' = If ($Guest.EmployeeLeaveDateTime) { Get-Date $Guest.EmployeeLeaveDateTime -format 'dd-MMMM-yyyy HH:mm' } else { $null }'Guest status' = $GuestStatus}$Report.Add($ReportItem)}$Report = $Report | Sort-Object Guest[datetime]$EndProcessing = Get-Date$TimeRequired = $EndProcessing - $StartProcessing$Minutes = [math]::Floor($TimeRequired.TotalSeconds / 60)$Seconds = [math]::Round($TimeRequired.TotalSeconds % 60, 2)If ($Interactive) {Write-Host ("Total processing time for {0} accounts: {1}m {2}s" -f $Guests.count, $Minutes, $Seconds) -ForegroundColor CyanWrite-Host ("Average required per user {0} seconds" -f [math]::Round($TimeRequired.TotalSeconds / $Guests.count, 2)) -ForegroundColor Cyan} Else {Write-Output ("Total processing time for {0} accounts: {1}m {2}s" -f $Guests.count, $Minutes, $Seconds)Write-Output ("Average required per user {0} seconds" -f [math]::Round($TimeRequired.TotalSeconds / $Guests.count, 2))}[array]$InactiveGuests = $Report | Where-Object { $_.'Guest status' -eq "Inactive" } | Sort-Object DisplayNameWrite-Host ""Write-Host ("Found {0} inactive guests ({1})" -f $InactiveGuests.Count,($InactiveGuests.Count/$Report.Count).toString("P")) -ForegroundColor GreenWrite-Host ""Write-Host "Inactive guests come from these domains"$InactiveGuests | Group-Object EmailDomain | Sort-Object Count -Descending | Format-Table Name,Count -AutoSize# Create a nice HTML report# Generate sortable HTML table with type-aware sorting - use number as the type for numeric values, date for dates, and string for text$HtmlHeader = @"<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Inactive Guest Accounts Report</title><style>body { font-family: Segoe UI, Arial, sans-serif; background: #f4f6f8; color: #222; }h1 { background: #0078d4; color: #fff; padding: 16px; border-radius: 6px 6px 0 0; margin-bottom: 20px; }table { width: 100%; background: #fff; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); border-collapse: collapse; }th, td { padding: 12px; text-align: left; }th { background: #e5eaf1; cursor: pointer; position: relative; }th:hover { background: #d0e7fa; }th::after { content: '↕'; position: absolute; right: 8px; opacity: 0.5; }tr:nth-child(even) { background: #f0f4fa; }tr:hover { background: #d0e7fa; }</style><script>function parseValue(val, type) {if(type === 'number') return parseFloat(val.replace(/,/g,'')) || 0;if(type === 'date') return new Date(val);return val.toLowerCase();}function sortTable(n, type) {var table = document.getElementById('GuestStats');var rows = Array.from(table.rows).slice(1);var dir = table.getAttribute('data-sortdir'+n) === 'asc' ? 'desc' : 'asc';rows.sort(function(a, b) {var x = parseValue(a.cells[n].innerText, type);var y = parseValue(b.cells[n].innerText, type);if(x < y) return dir === 'asc' ? -1 : 1;if(x > y) return dir === 'asc' ? 1 : -1;return 0;});rows.forEach(function(row) { table.tBodies[0].appendChild(row); });table.setAttribute('data-sortdir'+n, dir);}</script></head><body><h1>Inactive Guest Accounts Report</h1><table id="GuestStats"><thead><tr><th onclick="sortTable(0,'string')">Guest</th><th onclick="sortTable(1,'string')">Email</th><th onclick="sortTable(2,'string')">Sponsors</th><th onclick="sortTable(3,'date')">Creation date</th><th onclick="sortTable(4,'date')">Date of last successful signin</th><th onclick="sortTable(5,'date')">Date last audit activity</th><th onclick="sortTable(6,'number')">Number of audit activities</th><th onclick="sortTable(7,'string')">Top 3 activities</th><th onclick="sortTable(8,'string')">Guest status</th></tr></thead><tbody>"@$HtmlRows = foreach ($Row in $Report ) {"<tr><td>$($row.Guest)</td><td>$($row.Email)</td><td>$($row.Sponsors)</td><td>$($row.'Creation date')</td><td>$($row.'Date of last successful signin')</td><td>$($row.'Date last audit activity')</td><td>$($row.'Number of audit activities')</td><td>$($row.'Top 3 activities')</td><td>$($row.'Guest status')</td></tr>"}$HtmlFooter = @"</tbody></table></body></html>"@#Generate the full HTML content and save it to a fileIf ($Interactive) {$HTMLReportFile = ((New-Object -ComObject Shell.Application).Namespace('shell:Downloads').Self.Path) + "\InactiveGuests.html"} Else {$HTMLReportFile = $CurrentFolder + "\InactiveGuests.html"}$HTMLFile = $HtmlHeader + ($HtmlRows -join "`n") + $HtmlFooter$HTMLFile | Out-File -FilePath $HTMLReportFile -Encoding utf8Write-Host ("HTML report written to {0}" -f $HTMLReportFile) -ForegroundColor Green# And generate an output fileIf (Get-Module ImportExcel -ListAvailable) {$ExcelGenerated = $trueImport-Module ImportExcel -ErrorAction SilentlyContinue$ExcelOutputFile = ((New-Object -ComObject Shell.Application).Namespace('shell:Downloads').Self.Path) + "\InactiveGuests.xlsx"If (Test-Path $ExcelOutputFile) {Remove-Item $ExcelOutputFile -ErrorAction SilentlyContinue}$Report | Export-Excel -Path $ExcelOutputFile -WorksheetName "Inactive Guests" -Title ("Inactive Guests Report {0}" -f (Get-Date -format 'dd-MMM-yyyy')) -TitleBold -TableName "InactiveGuests"$AttachmentFile = $ExcelOutputFile} Else {$CSVOutputFile = ((New-Object -ComObject Shell.Application).Namespace('shell:Downloads').Self.Path) + "\InactiveGuests.CSV"$Report | Export-Csv -Path $CSVOutputFile -NoTypeInformation -Encoding Utf8$AttachmentFile = $CSVOutputFile}If ($ExcelGenerated) {Write-Host ("Excel worksheet output written to {0}" -f $ExcelOutputFile)} Else {Write-Host ("CSV output file written to {0}" -f $CSVOutputFile)}# Encode the output file to an email$EncodedAttachmentFile = [Convert]::ToBase64String([IO.File]::ReadAllBytes($AttachmentFile))# Encode the HTML report too$EncodedHTMLReportFile = [Convert]::ToBase64String([IO.File]::ReadAllBytes($HTMLReportFile))$MsgAttachments = @(@{'@odata.type' = '#microsoft.graph.fileAttachment'Name = (Split-Path $AttachmentFile -Leaf)ContentBytes = $EncodedAttachmentFileContentType = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'},@{'@odata.type' = '#microsoft.graph.fileAttachment'Name = (Split-Path $HTMLReportFile -Leaf)ContentBytes = $EncodedHTMLReportFileContentType = 'text/html'})# Build the array of a single TO recipient detailed in a hash table - change this to the appropriate recipient for your tenant$ToRecipient = @{}$ToRecipient.Add("emailAddress",@{'address'=$DestinationEmailAddress})[array]$MsgTo = $ToRecipient# Define the message subject$MsgSubject = "Important: Inactive Guests Report"# Create the HTML content$HtmlMsg = "</body></html><p>The output files for the <b>Inactive Guests Report</b> are attached to this message. Please review the information at your convenience</p>"# Construct the message body$MsgBody = @{}$MsgBody.Add('Content', "$($HtmlMsg)")$MsgBody.Add('ContentType','html')# Build the parameters to submit the message$Message = @{}$Message.Add('subject', $MsgSubject)$Message.Add('toRecipients', $MsgTo)$Message.Add('body', $MsgBody)$Message.Add("attachments", $MsgAttachments)$EmailParameters = @{}$EmailParameters.Add('message', $Message)$EmailParameters.Add('saveToSentItems', $true)$EmailParameters.Add('isDeliveryReceiptRequested', $true)# Send the messageTry {Send-MgUserMail -UserId $MsgFrom -BodyParameter $EmailParameters -ErrorAction StopWrite-Output ("Inactive guest account report emailed to {0}" -f $ToRecipient.emailAddress.address)} Catch {Write-Output "Unable to send email"Write-Output $_.Exception.Message}Write-Output "All done"
Parameters
ParameterDefaultNotes
-LookbackDays365Number of days of inactivity before a guest is considered inactive.-StartDate(Get-Date).AddDays(-30)Start of the audit log search window for guest activity.-EndDate(Get-Date).AddDays(1)End of the audit log search window for guest activity.-DestinationEmailAddress""Email address that receives the generated report.Attribution
Author
Office365itpros