Remove inactive user accounts
Azure Automation runbook that disables Entra ID accounts inactive beyond a threshold, marks them for review, and deletes accounts that remain disabled on a subsequent run.
Connect & set up
Run these once per session. All scopes are read-only unless the script makes changes.
# Review required modules and connection steps before running.# Connect to Microsoft Graph or Exchange Online as needed for this script.
Run it
The main script. Copy it, or download the .ps1 and run it from your console.
$TargetGroupId = '1cfc9ab3-d230-45f6-a81f-8e32de9ad95b'# Thresholds for inactivity (number of days)$DaysSinceLastSignInThreshold = 90# If no target group, process all licensed usersTry {[array]$TargetAccounts = Get-MgGroupMember -GroupId $TargetGroupId -All -PageSize 500 | Select-Object -ExpandProperty Id} Catch {Write-Output "Error retrieving group containing target accounts: $_"Write-Output "Checking for all user accounts instead"Try {[array]$TargetAccounts = Get-MgUser -Filter "assignedLicenses/`$count ne 0 and userType eq 'Member' and accountEnabled ne false" -ConsistencyLevel eventual `-CountVariable UsersFound -Property id, displayName, userprincipalname, usertype, signInActivity, SignInSessionsValidFromDateTime, LastPasswordChangeDateTime, passwordPolicies `-All -PageSize 500 -Sort displayName -ErrorAction Stop | Select-Object -ExpandProperty Id} Catch {Write-Output "Error retrieving all user accounts: $_"Break}}Write-Output "Found $($TargetAccounts.Count) target accounts to check for inactivity"# Before checking any account for inactivity, find the disabled accounts from the last run of the script. We don't want to reprocess these accounts and will delete them# at the end of the scriptTry {[array]$DisabledAccounts = Get-MgUser -Filter "accountEnabled eq false and onPremisesExtensionAttributes/extensionAttribute10 eq 'Inactive' and assignedLicenses/`$count ne 0 and userType eq 'Member'" `-All -PageSize 500 -ConsistencyLevel eventual -CountVariable DisabledAccountsFound} Catch {Write-Output "Error retrieving disabled accounts: $_"$DisabledAccounts = @()}$Report = [System.Collections.Generic.List[Object]]::new()ForEach ($Id in $TargetAccounts) {$DaysSinceLastSignIn = $null; $DaysSinceLastSuccessfulSignIn = $nullTry {$User = Get-MgUser -UserId $Id -Property id, displayName, userprincipalname, department, signInActivity, SignInSessionsValidFromDateTime, accountEnabled -ErrorAction Stop} Catch {Write-Output ("Error retrieving user with identifier {0}: $_" -f $Id)Continue}If (!([string]::IsNullOrWhiteSpace($User.signInActivity.lastSuccessfulSignInDateTime))) {[datetime]$LastSuccessfulSignIn = $User.signInActivity.lastSuccessfulSignInDateTime$DaysSinceLastSuccessfulSignIn = (New-TimeSpan $LastSuccessfulSignIn).Days}If (!([string]::IsNullOrWhiteSpace($User.signInActivity.lastSignInDateTime))) {[datetime]$LastSignIn = $User.signInActivity.lastSignInDateTime$DaysSinceLastSignIn = (New-TimeSpan $LastSignIn).Days}If ($DaysSinceLastSuccessfulSignIn -ge $DaysSinceLastSignInThreshold -or $DaysSinceLastSignIn -ge $DaysSinceLastSignInThreshold) {Write-Output ("User {0} ({1}) is inactive. Last sign-in {2} days ago, last successful sign-in {3} days ago" -f $User.displayName, $User.userPrincipalName, `$DaysSinceLastSignIn, $DaysSinceLastSuccessfulSignIn)# Collect details for reporting$ReportItem = [PSCustomObject]@{DisplayName = $User.DisplayNameUserPrincipalName = $User.UserPrincipalNameDepartment = $User.DepartmentAccountEnabled = $User.AccountEnabledLastSignInDateTime = $LastSignInDaysSinceLastSignIn = $DaysSinceLastSignInLastSuccessfulSignInDateTime = $LastSuccessfulSignInDaysSinceLastSuccessfulSignIn = $DaysSinceLastSuccessfulSignIn}$Report.Add($ReportItem)# Disable the user accountTry {Update-MgUser -UserId $User.Id -accountEnabled:$false -OnPremisesExtensionAttributes @{'extensionAttribute10' = 'Inactive'} -ErrorAction StopWrite-Output ("Disabled user account {0} ({1})" -f $User.DisplayName, $User.UserPrincipalName)} Catch {Write-Output ("Error disabling user account {0} ({1}): $_" -f $User.DisplayName, $User.UserPrincipalName)}} Else {Write-Output ("User {0} ({1}) is active. Last sign-in {2} days ago, last successful sign-in {3} days ago" -f $User.displayName, $User.userPrincipalName, `$DaysSinceLastSignIn, $DaysSinceLastSuccessfulSignIn)}}$Report | Sort-Object DaysSinceLastSuccessfulSignIn -Descending | Select-Object DisplayName, UserPrincipalName, LastSignInDateTime | Format-Table -AutoSize# Create an attachment for the email$Report | Export-CSV InactiveUserAccounts.CSV -NoTypeInformation -Encoding UTF8$Attachment = (Get-Location).Path + "\InactiveUserAccounts.CSV"$EncodedAttachmentFile = [Convert]::ToBase64String([IO.File]::ReadAllBytes($Attachment))$MsgAttachments = @(@{"@odata.type" = "#microsoft.graph.fileAttachment"Name = ($Attachment -split '\\')[-1]ContentType = "application/vnd.ms-excel"ContentBytes = $EncodedAttachmentFile})# Delete the accounts that are marked as disabled (by the last run of the script)If ($DisabledAccounts) {ForEach ($Account in $DisabledAccounts) {Try {Set-Mailbox -Identity $Account.Id -LitigationHoldEnabled $true -LitigationHoldDate (Get-Date) -LitigationHoldOwner "Administrator Workflow"Remove-MgUser -UserId $Account.Id -ErrorAction StopWrite-Output ("Deleted user account {0} ({1})" -f $Account.DisplayName, $Account.UserPrincipalName)} Catch {Write-Output ("Error deleting user account {0} ({1}): $_" -f $Account.DisplayName, $Account.UserPrincipalName)}}} Else {Write-Output "No disabled accounts found to delete"}$HtmlMsg = $null$HtmlMsg ="<h2>Inactive user account report - $(Get-Date -Format 'dd-MMM-yyyy')</h2>"$HtmlMsg += "<p>The inactive user accounts workflow found $($Report.Count) inactive accounts and disabled them</p>"$HtmlMsg += $Report | Select-Object DisplayName, UserPrincipalName, LastSignInDateTime | ConvertTo-Html -Fragment$HtmlMsg += "<p>The disabled accounts found by this workflow will be deleted the next time the inactive users workflow runs if no action is taken to re-enable them.</p>"If ($DisabledAccounts) {$HtmlMsg += "<h3>Accounts deleted</h3>"$HtmlMsg += "<p>The following accounts were marked as disabled by the last run of the script and have now been deleted:</p>"$HtmlMsg += $DisabledAccounts | Select-Object DisplayName, UserPrincipalName | ConvertTo-Html -Fragment} Else {$HtmlMsg += "<p>No accounts were deleted because no accounts were found that had been disabled by the last run of the workflow.</p>"}$HtmlMsg += "<p>This is an automated message generated by the Remove-InactiveUserAccounts.PS1 script. More information about the script can be found in the Office 365 for IT Pros GitHub repository.</p>"# Create and send a message to report what's hapened - update this address to suit your tenant$MsgFrom = 'Customer.Services@office365itpros.com'$MsgSubject = "Inactive user account workflow report"$ToRecipient = @{}# Update the target email address here to choose an appropriate recipient in your tenant (this could be a distribution list)$ToRecipient.Add("emailAddress",@{'address'="Tony.Redmond@office365itpros.com"})[array]$MsgTo = $ToRecipient# 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 {Write-Host "Sending email to $($MsgTo.emailAddress.address)" -ForegroundColor YellowSend-MgUserMail -UserId $MsgFrom -BodyParameter $EmailParameters} Catch {Write-Host "Failed to send email to $($MsgTo.emailAddress.address)" -ForegroundColor Red}Write-Output "Processing complete for inactive user workflow"