All scripts
Automation 471
Start-M365UserOffboarding
Full Microsoft 365 offboarding pass for one user or a CSV batch: disables sign in, revokes sessions, clears group/role/app assignments, cleans up the mailbox (hide from GAL, remove aliases, delete inbox rules, wipe mobile device, convert to shared, optional delegate access), removes licenses, and writes a CSV report. Supports -WhatIf.
Start-M365UserOffboarding.ps1
<#
.SYNOPSIS
Runs a full Microsoft 365 offboarding pass for one user or a batch of
users from a CSV, and writes a per-user results report at the end.
.DESCRIPTION
Covers the checks and cleanup an offboarding usually needs: disabling
sign in, revoking active sessions, clearing group and directory role
memberships, clearing enterprise app role assignments, and the mailbox
side of things (hide from the address list, remove secondary aliases,
delete inbox rules, wipe the mobile device partnership, convert to
shared, and optionally grant a manager delegate access). Licenses are
removed last so the account frees up a seat once everything else is
done. Every step is independent, so one failure does not stop the run,
and every outcome lands in the results table at the end.
.PARAMETER UserPrincipalName
UPN of a single user to offboard.
.PARAMETER CsvPath
Path to a CSV with a UserPrincipalName column, for offboarding several
users in one run. Takes priority over -UserPrincipalName if both are
supplied.
.PARAMETER DelegateEmail
Optional. Grants this mailbox Full Access and Send As on each user's
mailbox after it is converted to shared.
.PARAMETER IncludePasswordReset
Optional switch. Also resets each account's password to a random
value and writes it to a separate, more restricted log file, useful
as a second layer on top of disabling the account and revoking
sessions.
.PARAMETER ReportPath
Optional. Folder to write the results CSV and error log to. Defaults
to the current directory.
.EXAMPLE
.\Start-M365UserOffboarding.ps1 -UserPrincipalName jane.doe@contoso.com -DelegateEmail manager@contoso.com
.EXAMPLE
.\Start-M365UserOffboarding.ps1 -CsvPath .\leavers.csv -IncludePasswordReset -WhatIf
.NOTES
Requires the Microsoft.Graph.Users, Microsoft.Graph.Groups,
Microsoft.Graph.Identity.DirectoryManagement,
Microsoft.Graph.Applications and ExchangeOnlineManagement modules,
connected with at least User.ReadWrite.All, Group.ReadWrite.All,
RoleManagement.ReadWrite.Directory and
AppRoleAssignment.ReadWrite.All scopes.
.AUTHOR
Shehryar Hassan
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter()]
[string]$UserPrincipalName,
[Parameter()]
[string]$CsvPath,
[Parameter()]
[string]$DelegateEmail,
[Parameter()]
[switch]$IncludePasswordReset,
[Parameter()]
[string]$ReportPath = (Get-Location).Path
)
if ($CsvPath) {
$targets = (Import-Csv -Path $CsvPath).UserPrincipalName
} elseif ($UserPrincipalName) {
$targets = @($UserPrincipalName)
} else {
throw "Supply either -UserPrincipalName or -CsvPath."
}
$stamp = Get-Date -Format "yyyy-MM-dd_HHmmss"
$reportFile = Join-Path $ReportPath "offboarding-report_$stamp.csv"
$passwordFile = Join-Path $ReportPath "offboarding-passwords_$stamp.csv"
$errorFile = Join-Path $ReportPath "offboarding-errors_$stamp.log"
function Invoke-OffboardingStep {
param([string]$Name, [scriptblock]$Action, [string]$Target)
try {
$detail = & $Action
return [pscustomobject]@{ Step = $Name; Status = "Done"; Detail = $detail }
} catch {
"$Target | $Name | $($_.Exception.Message)" | Out-File -Append $errorFile
return [pscustomobject]@{ Step = $Name; Status = "Failed"; Detail = $_.Exception.Message }
}
}
$report = [System.Collections.Generic.List[pscustomobject]]::new()
foreach ($upn in $targets) {
$upn = $upn.Trim()
Write-Host ""
Write-Host "Offboarding $upn" -ForegroundColor Cyan
$user = Get-MgUser -UserId $upn -ErrorAction SilentlyContinue
if (-not $user) {
Write-Warning "No matching user for $upn, skipping."
continue
}
$steps = [System.Collections.Generic.List[pscustomobject]]::new()
if ($PSCmdlet.ShouldProcess($upn, "Disable account")) {
$steps.Add((Invoke-OffboardingStep "Disable account" { Update-MgUser -UserId $user.Id -AccountEnabled:$false } $upn))
$steps.Add((Invoke-OffboardingStep "Revoke sign in sessions" { Revoke-MgUserSignInSession -UserId $user.Id | Out-Null } $upn))
}
if ($IncludePasswordReset -and $PSCmdlet.ShouldProcess($upn, "Reset password")) {
$steps.Add((Invoke-OffboardingStep "Reset password" {
$newPassword = -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 14 | ForEach-Object { [char]$_ })
$profile = @{ forceChangePasswordNextSignIn = $true; password = $newPassword }
Update-MgUser -UserId $user.Id -PasswordProfile $profile
[pscustomobject]@{ UserPrincipalName = $upn; TemporaryPassword = $newPassword } | Export-Csv -Path $passwordFile -Append -NoTypeInformation
"logged separately"
} $upn))
}
if ($PSCmdlet.ShouldProcess($upn, "Clear group and role memberships")) {
$steps.Add((Invoke-OffboardingStep "Remove group memberships" {
$memberships = Get-MgUserMemberOf -UserId $user.Id -All
$groupCount = 0
foreach ($m in $memberships | Where-Object { $_.AdditionalProperties.'@odata.type' -eq '#microsoft.graph.group' -and $_.AdditionalProperties.groupTypes -notcontains 'DynamicMembership' }) {
try { Remove-MgGroupMemberByRef -GroupId $m.Id -DirectoryObjectId $user.Id -ErrorAction Stop; $groupCount++ } catch {}
}
"$groupCount group(s) removed"
} $upn))
$steps.Add((Invoke-OffboardingStep "Remove directory role assignments" {
$memberships = Get-MgUserMemberOf -UserId $user.Id -All
$roleCount = 0
foreach ($r in $memberships | Where-Object { $_.AdditionalProperties.'@odata.type' -eq '#microsoft.graph.directoryRole' }) {
try { Remove-MgDirectoryRoleMemberByRef -DirectoryRoleId $r.Id -DirectoryObjectId $user.Id -ErrorAction Stop; $roleCount++ } catch {}
}
"$roleCount role(s) removed"
} $upn))
$steps.Add((Invoke-OffboardingStep "Remove app role assignments" {
$assignments = Get-MgUserAppRoleAssignment -UserId $user.Id -All
foreach ($a in $assignments) { Remove-MgUserAppRoleAssignment -UserId $user.Id -AppRoleAssignmentId $a.Id -ErrorAction SilentlyContinue }
"$($assignments.Count) assignment(s) removed"
} $upn))
}
$hasMailbox = [bool](Get-Mailbox -Identity $upn -ErrorAction SilentlyContinue)
if ($hasMailbox -and $PSCmdlet.ShouldProcess($upn, "Clean up mailbox")) {
$steps.Add((Invoke-OffboardingStep "Hide from address lists" { Set-Mailbox -Identity $upn -HiddenFromAddressListsEnabled $true } $upn))
$steps.Add((Invoke-OffboardingStep "Remove secondary aliases" {
$aliases = (Get-Mailbox $upn).EmailAddresses | Where-Object { $_ -clike "smtp:*" }
if ($aliases) { Set-Mailbox $upn -EmailAddresses @{Remove = $aliases} -WarningAction SilentlyContinue }
"$($aliases.Count) alias(es) removed"
} $upn))
$steps.Add((Invoke-OffboardingStep "Delete inbox rules" {
$rules = Get-InboxRule -Mailbox $upn
$rules | Remove-InboxRule -Confirm:$false
"$($rules.Count) rule(s) removed"
} $upn))
$steps.Add((Invoke-OffboardingStep "Wipe mobile device partnership" {
$devices = Get-MobileDevice -Mailbox $upn -ErrorAction SilentlyContinue
$devices | Clear-MobileDevice -Confirm:$false -ErrorAction SilentlyContinue
"$($devices.Count) device(s) wiped"
} $upn))
$steps.Add((Invoke-OffboardingStep "Convert mailbox to shared" { Set-Mailbox -Identity $upn -Type Shared -WarningAction SilentlyContinue } $upn))
if ($DelegateEmail) {
$steps.Add((Invoke-OffboardingStep "Grant delegate mailbox access" {
Add-MailboxPermission -Identity $upn -User $DelegateEmail -AccessRights FullAccess -InheritanceType All -AutoMapping $false | Out-Null
Add-RecipientPermission -Identity $upn -Trustee $DelegateEmail -AccessRights SendAs -Confirm:$false | Out-Null
"Full Access and Send As for $DelegateEmail"
} $upn))
}
}
if ($PSCmdlet.ShouldProcess($upn, "Remove licenses")) {
$steps.Add((Invoke-OffboardingStep "Remove licenses" {
$skus = (Get-MgUserLicenseDetail -UserId $user.Id).SkuId
if ($skus) { Set-MgUserLicense -UserId $user.Id -AddLicenses @() -RemoveLicenses $skus | Out-Null }
"$($skus.Count) license(s) removed"
} $upn))
}
foreach ($s in $steps) {
$report.Add([pscustomobject]@{
UserPrincipalName = $upn
Step = $s.Step
Status = $s.Status
Detail = $s.Detail
})
}
$steps | Format-Table -AutoSize
}
$report | Export-Csv -Path $reportFile -NoTypeInformation
Write-Host ""
Write-Host "Report written to $reportFile" -ForegroundColor Cyan
if ($IncludePasswordReset) { Write-Host "Temporary passwords written to $passwordFile" -ForegroundColor Yellow }
if (Test-Path $errorFile) { Write-Host "Some steps failed, see $errorFile" -ForegroundColor Red }
Read it before you run it, and test in a safe tenant first.