The script below will help you to automate your inactive guest account cleanups in Entra ID (Azure AD).
The script handles nested groups in your main YOURGROUPNAMEHERE group, it will exclude any user or group it finds. The reason why adding your users to groups is to work with Entra ID access reviews to keep the exclusions to a healthy minimum.
In the end the script does a final check of the guests accounts it can find and does a compare for a final sum sanity check.
You only need to add logic to send yourself an email (or whatever) and output the stringbuilder.
# Parameters
$ClientID = "YourClientIDHere"
$AppId = "YourAppIdHere"
$ThumbPrint = "CertThumbprintHere"
$Tenant = "YourTenantPrimaryURLHere"
# Import modules
Import-Module Microsoft.Graph.Users
Import-Module Microsoft.Graph.Groups
Import-Module ExchangeOnlineManagement
# Connect with Azure AD and Exchange Online
Connect-MgGraph -ApplicationId "$AppId" -CertificateThumbprint "$ThumbPrint" -Tenant "$Tenant" -NoWelcome
# Connect with Exchange Online
Connect-ExchangeOnline -CertificateThumbPrint "ThumbPrint" -AppID "$AppId" -Organization "$Tenant"
### VARIABLES ###
$DeductDays = "89"
$Guests = (Get-MgUser -Filter "UserType eq 'Guest'" -All -Property Displayname, Id, UserPrincipalName, Mail, RefreshTokensValidFromDateTime, SignInActivity | Select-Object Displayname, Id, UserPrincipalName, Mail, RefreshTokensValidFromDateTime, @{n="LastSignInDateTime";e={$_.SignInActivity.LastSignInDateTime}}) | Sort-Object
$totalGuests = $Guests.Count
Write-Host "$totalGuests guest accounts found. Checking their recent activity..."
$StartDate = (Get-Date).AddDays(-$DeductDays)
$StartDate2 = (Get-Date).AddDays(-10) # Message trace can only go back 10 days.
$Report = [System.Collections.Generic.List[Object]]::new() # Create output file for report
$counter = 0
$EndDate = (Get-Date);
$Active = 0
$EmailActive = 0
$Inactive = 0
$AuditRec = 0
###
# Loop over every currently existing guest and check their (in)activity
foreach ($guest in $Guests)
{
$counter++
$displayname = $guest.DisplayName
Write-Host ""
Write-Host "Checking $DisplayName | guest $counter of $totalGuests"
$LastAuditAction = $Null; $LastAuditRecord = $Null
# Search for audit records for this user
$Recs = (Search-UnifiedAuditLog -UserIds $guest.Mail, $guest.UserPrincipalName -Operations UserLoggedIn, SecureLinkUsed, TeamsSessionStarted -StartDate $StartDate -EndDate $EndDate -ResultSize 1)
if ($Null -ne $Recs.CreationDate)
{
# We found some audit logs
$LastAuditRecord = $Recs[0].CreationDate;
$LastAuditAction = $Recs[0].Operations;
$AuditRec++
Write-Host "Last audit record for $DisplayName on $LastAuditRecord"
}
else
{
# Check message trace data because guests might receive email through membership of Outlook Groups. Email address must be valid for the check to work
If ($Null -ne $guest.Mail)
{
#get all email records
$EmailRecs = (Get-MessageTrace –StartDate $StartDate2 –EndDate $EndDate -Recipient $guest.Mail)
}
$displayname= $guest.DisplayName
$RefreshTokensValidFromDateTime = $guest.SignInActivity
Write-Host "No audit records found in the last $DeductDays days for $DisplayName ; account last logged in on $RefreshTokensValidFromDateTime"
# Write out report line
$ReportLine = [PSCustomObject]@{
Guest = $guest.Mail
Name = $guest.DisplayName
ObjectID = $guest.Id
Created = $guest.RefreshTokensValidFromDateTime
EmailCount = $EmailRecs.Count
LastConnectOn = $LastAuditRecord
LastConnect = $LastAuditAction
}
$Report.Add($ReportLine)
}
Write-Host ""
}
###
# Every user that did not do a login in the last $DeductDays days are in the $report
$reportCount = $report.Count
$activeUsers = $totalGuests - $reportCount
$userGroup = ""
# Create a $removedusers stringbuilder
$removedusers = New-Object System.Text.StringBuilder
$removedusers.Append("Hi, <br></p>")
$removedusers.Append("<p>A total of <b>$totalGuests</b> guest accounts were processsed, of which <b>" + $activeUsers + "</b> are active.</p>")
$removedusers.Append("<b>" + $reportCount + "</b> haven't signed in the last $DeductDays days.</p>")
$removedusers.Append("<br>&nbsp;")
$removedusers.Append("<p>Below you can find the full list of these <b>inactive guests accounts.</b></p>")
# creation of html table
$RemovedUsers.Append("<style> tr:nth-child(even) { background-color: #f2f2f2; } tr:hover {background-color: #dbf0f8;} </style>")
$removedusers.Append("<table style='width:100%%' class='InactiveGuestAccountsTable' id='InactiveGuestAccountsTable'>")
$removedusers.Append("<tr>")
$removedusers.Append("<th>#</th>")
$removedusers.Append("<th>Name</th>")
$removedusers.Append("<th>Mail</th>")
$removedusers.Append("<th>Group membership</th>")
$removedusers.Append("<th>Action</th>")
$removedusers.Append("</tr>")
# Go over each line in the report and check the $DeductDays days marker
$lineExcludedCounter = 0
$lineRemovedCounter = 0
$lineCounter = 0
# YOURGROUPNAMEHERE = "YOURGROUPIDHERE"
$group = Get-MgGroup -GroupId "YOURGROUPIDHERE"
$members = Get-MgGroupMember -GroupId $group.Id -All
foreach($line in $report)
{
$lineCounter++
# Get today minus the deductdays
$DeductDate = (get-date).adddays(-$DeductDays)
# Check if users haven't connect less then the $deductdate
if($line.LastConnectOn -lt $DeductDate)
{
Write-Host $line.ObjectID
Write-Host $line.Name
# Groupcounter will be used to indicated whether the user is in the YOURGROUPNAMEHERE-Excluded_Members group.
$GroupCounter = 0
$userGroup = ""
foreach ($member in $members)
{
Write-Host "Checking member = $member"
# If found object is a user
if ($member.AdditionalProperties.'@odata.type' -eq "#microsoft.graph.user")
{
Write-Host "Member is a direct part of the guest group (not good)"
if($member.Id -eq $line.ObjectID)
{
$lineExcludedCounter++
$user = Get-MgUser -UserId $member.Id
Write-Host "Excluded direct member found"
Write-Host $member.Id
Write-Host $member.AdditionalProperties.displayName
$userGroup = "<span style='background-color: #FFA500;'><b>[WARNING]</b></span> Direct member detected in <a href='https://portal.azure.com/?feature.msaljs=true#view/Microsoft_AAD_IAM/GroupDetailsMenuBlade/~/Members/groupId/YOURGROUPIDHERE/menuId/'>YOURGROUPNAMEHERE</a>"
Write-Host "Direct member found = $($user.DisplayName)"
$GroupCounter = 666 # User can't be removed
break
}
}
elseif ($member.AdditionalProperties.'@odata.type' -eq "#microsoft.graph.group")
{
Write-Host "Member is part of a group"
$nestedGroup = Get-MgGroup -GroupId $member.Id
$nestedMembers = Get-MgGroupMember -GroupId $nestedGroup.Id -All
foreach ($nestedMember in $nestedMembers)
{
Write-Host "Going over nested member = $nestedMember.AdditionalProperties.displayName"
if ($nestedMember.AdditionalProperties.'@odata.type' -eq "#microsoft.graph.user")
{
Write-Host "Nested member is a user"
if($nestedMember.Id -eq $line.ObjectID)
{
Write-Host "If the nested member is the same as in the parent foreach"
$lineExcludedCounter++
$nestedUser = Get-MgUser -UserId $nestedMember.Id
Write-Host "Excluded nested member found"
Write-Host $nestedUser.id
Write-Host $nestedUser.DisplayName
$userGroup = "Nested member -> <a href='https://portal.azure.com/?feature.msaljs=true#view/Microsoft_AAD_IAM/GroupDetailsMenuBlade/~/Members/groupId/$($nestedGroup.Id)/menuId/'>$($nestedGroup.DisplayName)</a>"
Write-Host "Excluded nested member found = $($nestedGroup.DisplayName)"
Write-Host "$userGroup"
$GroupCounter = 666
break
}
}
}
}
}
if($GroupCounter -eq 0)
{
Write-Host "Member is NOT protected, removal started for $line.guest"
try
{
$lineRemovedCounter++
Remove-MgUser -UserId $line.ObjectID
Write-Host $line.guest " has been removed successfully"
Write-Host "$line.guest"
$removedusers.Append("<tr>")
$removedusers.Append("<td>" + $lineCounter + "</td>")
$removedusers.Append("<td>" + $line.Name + "</td>")
$removedusers.Append("<td>" + $line.Guest + "</td>")
$removedusers.Append("<td>Standard guest account</td>")
$removedusers.Append("<td style='background-color: #FF0000;'>Removed</td>")
$removedusers.Append("</tr>")
}
catch
{
Write-Host "$line.guest could not be removed!"
Write-Host "$_"
$removedusers.Append("<tr>")
$removedusers.Append("<td>" + $lineCounter + "</td>")
$removedusers.Append("<td>" + $line.Name + "</td>")
$removedusers.Append("<td>" + $line.Guest + "</td>")
$removedusers.Append("<td>" + $userGroup + "</td>")
$removedusers.Append("<td style='background-color: #FF0000;'>Could not be removed!</td>")
$removedusers.Append("</tr>")
}
}
elseif ($GroupCounter -eq 666)
{
if($NULL -ne $line.LastConnectOn)
{
Write-Host $line.LastConnectOn
$lastConnectOn = [datetime]::parseexact($line.LastConnectOn, 'MM/dd/yyyy HH:mm:ss', $null).ToString('yyyy-MM-dd')
}
else
{
$lastConnectOn = "User has not logged in for over $DeductDays days"
}
if($NULL -eq $userGroup)
{
$userGroup = "Not excluded user"
}
$removedusers.Append("<tr>")
$removedusers.Append("<td>" + $lineCounter + "</td>")
$removedusers.Append("<td>" + $line.Name + "</td>")
$removedusers.Append("<td>" + $line.Guest + "</td>")
$removedusers.Append("<td>" + $userGroup + "</td>")
$removedusers.Append("<td style='background-color: #90EE90;'>Excluded</td>")
$removedusers.Append("</tr>")
Write-Host "User $guest Excluded Guest Member"
}
}
}
$excludedUsersCounter = $reportCount - $lineRemovedCounter
$sumcheck = ( ($activeUsers + $lineRemovedCounter) + $excludedUsersCounter)
Write-Host "sum = $sumcheck"
$sumCheckMessage = ""
if($sumCheck -eq $totalGuests)
{
$sumCheckMessage = "<span style='color: #90EE90'>MATCHES = $sumCheck</span>"
}
else
{
$sumCheckMessage = "<span style='color: #FF0000'>DOES NOT MATCH = $sumCheck</span>"
}
$removedusers.Append("<caption style='caption-side:bottom'>")
$removedusers.Append("<ul>")
$removedusers.Append("<li><b>Active:</b> $activeUsers of $totalGuests are active.</li>")
$removedusers.Append("<li><b>Removed:</b> $lineRemovedCounter of $reportCount inactive guest accounts were removed.</li>")
$removedusers.Append("<li><b>Excluded:</b> $excludedUsersCounter of $reportCount inactive guest accounts were excluded.</li>")
$removedusers.Append("<li><b>Control:</b> The SUM of the Active ($activeUsers) + Removed ($lineRemovedCounter) + Excluded ($excludedUsersCounter) must be equal to the total Guests ($totalGuests) => $sumCheckMessage</li>")
$removedusers.Append("</ul>")
$removedusers.Append("</caption>")
$removedusers.Append("</table>")
$removedusers.Append("Direct members group link = <a href='https://portal.azure.com/?feature.msaljs=true#view/Microsoft_AAD_IAM/GroupDetailsMenuBlade/~/Members/groupId/YOURGROUPIDHERE/menuId/'>YOURGROUPNAMEHERE</a>")
$removedusers.Append("<br>&nbsp;")
$removedusers.Append("<p>Kind regards,<br><br>NAMEHERE</p>")
###
# Ticketing system API or mail messaging logic here
$subjectDate = get-date -Format "yyyy-MM"
$subject = $subjectDate + "Your Monthly Guest Cleanup"
# variable with stringbuilder information
$removedusers.ToString()
Write-Host "Ending script"
Example output



