SCRIPT LIBRARY · POWERSHELL
Automating User Offboarding in Azure AD with PowerShell
One script that locks a departing user out, cleans up their groups and licenses, and keeps their mail, with a CSV record of every step.
- What it does
- Blocks sign-in, revokes sessions, removes group memberships and direct licenses, and can convert the mailbox to shared and forward mail. Every step is logged to CSV, and -WhatIf shows the whole plan without touching anything.
- Requires
- PowerShell 7.2+ (Windows PowerShell 5.1 works too)
- Microsoft.Graph module (Users, Users.Actions, Groups)
- ExchangeOnlineManagement module, for the mailbox steps and distribution lists
- Permissions
- Graph scopes: User.ReadWrite.All, GroupMember.ReadWrite.All, LicenseAssignment.ReadWrite.All. For the mailbox steps: an Exchange admin role.
- Runs on
- Windows, macOS, Linux
- Tested
- Parse-checked and dry-run with mocked Graph and Exchange cmdlets in PowerShell 7.4, including -WhatIf
Part 5 of the thread The Microsoft Graph toolbox
Offboarding is one of those jobs that's simple right up until it isn't. Disable the account, pull the license, done. Right?
Then the mailbox vanishes 30 days later because nobody converted it first. Or the "disabled" user is still reading email on their phone because their sessions were never revoked. Or the script dies halfway through because it hit a distribution list, and now you're not sure which steps actually ran.
This version handles those. It works through the whole checklist in a sensible order, skips what it shouldn't touch (and tells you why), keeps going if one step fails, and writes down everything it did. Run it with -WhatIf first. You'll see exactly what's going to happen before anything does.
(The original 2024 version of this post used the AzureAD module, which Microsoft has since retired. This one is built on Microsoft Graph PowerShell.)
<#
.SYNOPSIS
Offboards a Microsoft Entra ID user: blocks sign-in, kills sessions, cleans up groups
and licenses, and can hand their mailbox off before the license goes away.
.DESCRIPTION
Uses the Microsoft Graph PowerShell SDK for the directory work and Exchange Online
PowerShell for the mailbox work. Every step is logged to a CSV so there's a record of
exactly what happened. One failed step doesn't stop the rest. Supports -WhatIf.
.PARAMETER UserPrincipalName
The user to offboard.
.PARAMETER ForwardTo
Forward the user's mail to this address (usually their manager). Needs ExchangeOnlineManagement.
.PARAMETER ConvertToShared
Convert the mailbox to a shared mailbox before licenses are removed, so the mail is kept.
.PARAMETER KeepGroups
Leave group memberships alone.
.PARAMETER LogPath
Where to write the CSV log. Defaults to a timestamped file in the current folder.
.EXAMPLE
.\Invoke-UserOffboarding.ps1 -UserPrincipalName [email protected] -WhatIf
.EXAMPLE
.\Invoke-UserOffboarding.ps1 -UserPrincipalName [email protected] -ConvertToShared -ForwardTo [email protected]
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)][string]$UserPrincipalName,
[string]$ForwardTo,
[switch]$ConvertToShared,
[switch]$KeepGroups,
[string]$LogPath = (Join-Path (Get-Location) ('offboarding-{0}-{1:yyyyMMdd-HHmm}.csv' -f ($UserPrincipalName -replace '[^\w.-]', '_'), (Get-Date)))
)
$ErrorActionPreference = 'Stop'
$log = [System.Collections.Generic.List[object]]::new()
$exchangeReady = $false
function Write-Step {
param([string]$Action, [string]$Target, [string]$Result, [string]$Detail = '')
$log.Add([pscustomobject]@{ Time = (Get-Date).ToString('s'); Action = $Action; Target = $Target; Result = $Result; Detail = $Detail })
$color = switch ($Result) { 'Done' { 'Green' } 'Skipped' { 'Yellow' } 'WhatIf' { 'Cyan' } default { 'Red' } }
Write-Host ("[{0}] {1}: {2} {3}" -f $Result, $Action, $Target, $Detail).TrimEnd() -ForegroundColor $color
}
# Runs one step, honors -WhatIf, and logs the outcome. Returns $true if the step ran cleanly.
function Invoke-Step {
param([string]$Action, [string]$Target, [scriptblock]$Do)
if (-not $PSCmdlet.ShouldProcess($Target, $Action)) { Write-Step $Action $Target 'WhatIf'; return $true }
try { & $Do; Write-Step $Action $Target 'Done'; return $true }
catch { Write-Step $Action $Target 'Failed' $_.Exception.Message; return $false }
}
# Connects to Exchange Online the first time it's needed. Returns $true when it's usable.
function Connect-Exchange {
if ($script:exchangeReady) { return $true }
if (-not (Get-Command Connect-ExchangeOnline -ErrorAction SilentlyContinue)) {
Write-Step 'Connect to Exchange Online' 'ExchangeOnlineManagement' 'Failed' 'Module not installed: Install-Module ExchangeOnlineManagement'
return $false
}
if (-not (Get-ConnectionInformation -ErrorAction SilentlyContinue)) { Connect-ExchangeOnline -ShowBanner:$false }
$script:exchangeReady = $true
return $true
}
# --- Connect and look the user up ---------------------------------------------------------
$scopes = 'User.ReadWrite.All', 'GroupMember.ReadWrite.All', 'LicenseAssignment.ReadWrite.All'
if (-not (Get-MgContext)) { Connect-MgGraph -Scopes $scopes -NoWelcome }
$user = Get-MgUser -UserId $UserPrincipalName -Property 'Id,DisplayName,UserPrincipalName,AccountEnabled,OnPremisesSyncEnabled,LicenseAssignmentStates'
$upn = $user.UserPrincipalName
Write-Host "Offboarding $($user.DisplayName) <$upn>"
# --- 1. Block sign-in -----------------------------------------------------------------------
if ($user.OnPremisesSyncEnabled) {
Write-Step 'Block sign-in' $upn 'Skipped' 'Synced from on-prem AD. Disable it there, or the next sync turns it back on.'
}
elseif (-not $user.AccountEnabled) {
Write-Step 'Block sign-in' $upn 'Skipped' 'Already disabled.'
}
else {
[void](Invoke-Step 'Block sign-in' $upn { Update-MgUser -UserId $user.Id -AccountEnabled:$false })
}
# --- 2. Kill existing sessions --------------------------------------------------------------
[void](Invoke-Step 'Revoke sign-in sessions' $upn { Revoke-MgUserSignInSession -UserId $user.Id | Out-Null })
# --- 3. Mailbox hand-off (before licenses, or the mailbox is on a 30-day clock) --------------
$mailboxSafe = -not $ConvertToShared
if ($ConvertToShared -or $ForwardTo) {
if (Connect-Exchange) {
if ($ConvertToShared) {
$mailboxSafe = Invoke-Step 'Convert mailbox to shared' $upn { Set-Mailbox -Identity $upn -Type Shared }
}
if ($ForwardTo) {
[void](Invoke-Step 'Forward mail' "$upn -> $ForwardTo" { Set-Mailbox -Identity $upn -ForwardingSmtpAddress "smtp:$ForwardTo" -DeliverToMailboxAndForward $true })
}
}
}
# --- 4. Group memberships -------------------------------------------------------------------
if ($KeepGroups) {
Write-Step 'Remove from groups' $upn 'Skipped' '-KeepGroups was set.'
}
else {
$memberships = @(Get-MgUserMemberOf -UserId $user.Id -All | Where-Object { $_.AdditionalProperties['@odata.type'] -eq '#microsoft.graph.group' })
foreach ($membership in $memberships) {
$group = Get-MgGroup -GroupId $membership.Id -Property 'Id,DisplayName,Mail,GroupTypes,MailEnabled,SecurityEnabled,OnPremisesSyncEnabled'
$name = $group.DisplayName
if ($group.GroupTypes -contains 'DynamicMembership') {
Write-Step 'Remove from group' $name 'Skipped' "Dynamic group. Membership follows its rule, so change the user's attributes instead."
continue
}
if ($group.OnPremisesSyncEnabled) {
Write-Step 'Remove from group' $name 'Skipped' 'Synced from on-prem AD. Remove the user there.'
continue
}
if ($group.MailEnabled -and -not ($group.GroupTypes -contains 'Unified')) {
# Distribution lists and mail-enabled security groups are read-only in Graph.
if (Connect-Exchange) {
[void](Invoke-Step 'Remove from distribution group' $name { Remove-DistributionGroupMember -Identity $group.Mail -Member $upn -BypassSecurityGroupManagerCheck -Confirm:$false })
}
continue
}
[void](Invoke-Step 'Remove from group' $name { Remove-MgGroupMemberDirectoryObjectByRef -GroupId $group.Id -DirectoryObjectId $user.Id })
}
}
# --- 5. Licenses ----------------------------------------------------------------------------
# Plain property access here: ForEach-Object -MemberName honors -WhatIf and would silently return nothing.
$directSkus = @(@($user.LicenseAssignmentStates | Where-Object { -not $_.AssignedByGroup }).SkuId | Select-Object -Unique)
$groupLicense = @($user.LicenseAssignmentStates | Where-Object { $_.AssignedByGroup })
if (-not $mailboxSafe) {
Write-Step 'Remove licenses' $upn 'Skipped' "The mailbox didn't convert to shared, so licenses were kept to protect the mail."
}
elseif ($directSkus.Count -eq 0) {
Write-Step 'Remove licenses' $upn 'Skipped' 'No directly assigned licenses.'
}
else {
[void](Invoke-Step "Remove $($directSkus.Count) direct license(s)" $upn { Set-MgUserLicense -UserId $user.Id -AddLicenses @() -RemoveLicenses $directSkus | Out-Null })
}
if ($groupLicense.Count -and $KeepGroups) {
Write-Step 'Group-based licenses' $upn 'Skipped' "$($groupLicense.Count) license(s) come from group membership and stay until the user leaves those groups."
}
# --- Save the record ------------------------------------------------------------------------
$log | Export-Csv -Path $LogPath -NoTypeInformation -WhatIf:$false
Write-Host "Log saved to $LogPath"
Parameters
| Parameter | Type | Default | What it's for |
|---|---|---|---|
-UserPrincipalName | string | — | The user you're offboarding. Required. |
-ForwardTo | string | — | Forward their mail to this address, usually their manager. A copy stays in the mailbox too. |
-ConvertToShared | switch | — | Convert the mailbox to shared before the license comes off, so nothing gets deleted. |
-KeepGroups | switch | — | Leave group memberships alone, for when you need a slower handover. |
-LogPath | string | .\offboarding-<user>-<time>.csv | Where to save the record of what happened. |
-WhatIf | switch | — | Show every step without making a single change. Always start here. |
Run it
The dry run. Nothing changes, and you see the whole plan.
.\Invoke-UserOffboarding.ps1 -UserPrincipalName [email protected] -ConvertToShared -WhatIfThe full treatment. Mailbox kept, mail forwarded to their manager.
.\Invoke-UserOffboarding.ps1 -UserPrincipalName [email protected] -ConvertToShared -ForwardTo [email protected]Lock them out today, and deal with groups after the handover.
.\Invoke-UserOffboarding.ps1 -UserPrincipalName [email protected] -KeepGroupsWhat you'll see
Offboarding Jane Doe <[email protected]>
[Done] Block sign-in: [email protected]
[Done] Revoke sign-in sessions: [email protected]
[Done] Convert mailbox to shared: [email protected]
[Done] Forward mail: [email protected] -> [email protected]
[Done] Remove from group: Marketing Team
[Done] Remove from group: VPN Users
[Skipped] Remove from group: All Employees Dynamic group. Membership follows its rule, so change the user's attributes instead.
[Done] Remove from distribution group: Sales DL
[Failed] Remove from group: Helpdesk Admins Insufficient privileges to complete the operation.
[Done] Remove 2 direct license(s): [email protected]
Log saved to .\offboarding-jane.doe_contoso.com-20260929-1415.csv
How it works
It's a checklist, run in the order that keeps you out of trouble.
- Block sign-in. The account is disabled with
Update-MgUser. If it's synced from on-prem Active Directory, the script skips this step and tells you why. - Revoke sessions.
Revoke-MgUserSignInSessioninvalidates refresh tokens, so an already-signed-in phone or laptop gets kicked out, usually within the hour, instead of whenever the token happens to expire. - Hand off the mailbox. If you asked for it, the mailbox is converted to shared and forwarding is set up. This happens before the licenses come off, on purpose.
- Clean up groups. It checks each group and handles it the right way. Regular groups go through Graph, distribution lists go through Exchange, and dynamic or on-prem-synced groups are skipped with a note.
- Remove licenses. Only the directly assigned ones. Group-based licenses leave when the group membership does.
- Write it all down. Every step lands in a CSV with a timestamp and a Done, Skipped, Failed, or WhatIf result. That's handy when someone asks, three weeks later, whether the offboarding actually happened.
One failed step doesn't stop the others. You'd rather have a user who's 90% offboarded, with a clear note about the other 10%, than a script that bailed at step two.
Take it further
- Run it for a list. Feed it a CSV from HR:
Import-Csv .\leavers.csv | ForEach-Object { .\Invoke-UserOffboarding.ps1 -UserPrincipalName $_.UPN -ConvertToShared }. - Hand off OneDrive too. Grant the manager access to the user's OneDrive before the account is deleted, so their files don't go with it.
- Run it unattended. Swap the interactive sign-in for an app registration with certificate auth, and it can run from Azure Automation as part of your HR workflow.
Things that'll trip you up
- Convert the mailbox before the license comes off. Pull the license first and the mailbox starts a 30-day countdown to deletion. The script always converts first, and if the conversion fails, it keeps the licenses rather than risk the mail.
- Synced users have to be disabled on-prem. If the account comes from on-prem Active Directory, disabling it in the cloud doesn't stick. The next sync turns it right back on. The script skips that step and tells you to disable the account in AD instead.
- Some groups aren't Graph's to change. Dynamic groups follow their membership rules, so change the user's attributes instead. Distribution lists and mail-enabled security groups can only be edited in Exchange, which the script handles when the Exchange module is installed.
- Admin users need an admin to offboard them. Disabling someone with an admin role, or removing them from a role-assignable group, takes a privileged role on your side too. Those steps show up as Failed in the log instead of stopping the run.
- Group-based licenses leave with the group. Licenses assigned through a group can't be removed directly. They drop off when the user leaves that group, which is one more reason -KeepGroups is a deliberate choice.
- Forwarding outside your organization may be blocked. Microsoft 365's default outbound spam policy stops automatic forwarding to external addresses. Internal forwarding works fine.
- The -WhatIf Trap Hiding in ForEach-ObjectEngineering