Offboarding is the workflow that hurts the most when it goes wrong. A leaver who keeps an active session, a former contractor whose Teams membership lingers, a shared mailbox that no one inherits: each one is a small audit finding waiting to happen. Onboarding gets attention because it produces a new working employee; offboarding gets attention only when something blows up. This post walks through a small, production-shaped Microsoft Graph PowerShell pipeline that takes a single HR row and turns a leaver into a clean directory state in under a minute, with every step logged and recoverable.

Why offboarding deserves its own script

The dangerous step in offboarding is not deletion, it is the gap between “still has access” and “no longer has access”. A wiki-driven manual process leaves windows open: the account is disabled but refresh tokens are still valid, the licence is removed but the mailbox is unreachable for the team, the user is gone from Entra ID but still owns 80GB of OneDrive content nobody can see. A script collapses those windows to seconds, in the right order, with the same outcome every time. Just as importantly, it produces a single audit row per leaver that you can hand to security or HR without piecing the story together from five different consoles.

Prerequisites

  • PowerShell 7.4 or later, Microsoft.Graph 2.20 or newer, and the ExchangeOnlineManagement module: Install-Module Microsoft.Graph, ExchangeOnlineManagement -Scope CurrentUser.
  • A service principal or admin account with the User Administrator, Exchange Administrator, and SharePoint Administrator roles, plus the Graph permissions User.ReadWrite.All, Group.ReadWrite.All, Directory.AccessAsUser.All, and Sites.FullControl.All.
  • A clear hand-off destination for orphaned data, typically the leaver’s manager. The manager UPN should already be present on the user object (the onboarding script in the previous post sets it; if not, fall back to your HR feed).

Step 1: Disable, revoke, and lock the door immediately

The first three actions are the security-critical ones and should run before anything else, in this exact order: disable the account, revoke active sessions and refresh tokens, then reset the password to a random value the user never sees. Doing this in any other order leaves a small window where a still-authenticated session can refresh its token before being cut off.

function Stop-UserAccess {
    param([Parameter(Mandatory)][string]$UserPrincipalName)

    $user = Get-MgUser -UserId $UserPrincipalName -Property Id, DisplayName, Mail, Manager
    if (-not $user) { throw "User $UserPrincipalName not found" }

    # 1. Disable the account
    Update-MgUser -UserId $user.Id -AccountEnabled:$false
    Write-Host ("Disabled {0}" -f $user.UserPrincipalName)

    # 2. Revoke all sign-in sessions and refresh tokens
    Invoke-MgGraphRequest -Method POST -Uri ('/v1.0/users/{0}/revokeSignInSessions' -f $user.Id) | Out-Null
    Write-Host '  sessions revoked'

    # 3. Reset the password so any cached credential is dead
    $rand = -join ((33..126) | Get-Random -Count 24 | ForEach-Object {[char]$_})
    Update-MgUser -UserId $user.Id -PasswordProfile @{
        password = $rand
        forceChangePasswordNextSignIn = $true
    }
    Write-Host '  password rotated'

    return $user
}

The revokeSignInSessions action invalidates every refresh token tied to the user, which is the only reliable way to terminate active Microsoft 365 web sessions across all clients. It is idempotent and free, so include it even if you think the user is offline. Conditional Access policies that require compliant or hybrid-joined devices will not retroactively log a leaver out; this call will.

Step 2: Mailbox handover and out-of-office

Mail is the part of an account a manager actually wants. Convert the mailbox to a shared mailbox so it stops counting against licences, set an automatic reply, and grant the manager Full Access plus Send-As. Shared mailboxes do not need a licence as long as they stay under 50GB, which is the cheap-and-correct destination for a leaver’s mail history.

Connect-ExchangeOnline -ShowBanner:$false

function Convert-LeaverMailbox {
    param([Parameter(Mandatory)]$User, [Parameter(Mandatory)][string]$ManagerUpn)

    Set-Mailbox -Identity $User.UserPrincipalName -Type Shared
    Write-Host ("Converted mailbox to shared for {0}" -f $User.UserPrincipalName)

    # Out-of-office for any late mail
    $reply = "I am no longer with the company. For business matters please contact $ManagerUpn."
    Set-MailboxAutoReplyConfiguration -Identity $User.UserPrincipalName `
        -AutoReplyState Enabled `
        -InternalMessage $reply `
        -ExternalMessage $reply `
        -ExternalAudience All

    # Manager gets Full Access plus Send-As
    Add-MailboxPermission -Identity $User.UserPrincipalName -User $ManagerUpn `
        -AccessRights FullAccess -InheritanceType All -AutoMapping:$true | Out-Null
    Add-RecipientPermission -Identity $User.UserPrincipalName -Trustee $ManagerUpn `
        -AccessRights SendAs -Confirm:$false | Out-Null
    Write-Host ("  delegated to {0}" -f $ManagerUpn)
}

If the leaver sat in a regulated function, set a litigation hold on the shared mailbox before doing anything else: Set-Mailbox -Identity $upn -LitigationHoldEnabled $true -LitigationHoldDuration 2555. This freezes mail for seven years and survives the conversion to shared.

Step 3: OneDrive transfer, group cleanup, and licences

OneDrive ownership transfer needs a SharePoint admin call, group removal needs Graph, and licence removal can wait until the very end so the mailbox conversion has time to complete. Group memberships are listed via Graph and removed one by one to keep the audit trail clean (a single bulk removal is faster but harder to audit when something goes wrong).

function Complete-Offboarding {
    param([Parameter(Mandatory)]$User, [Parameter(Mandatory)][string]$ManagerUpn)

    # Group cleanup
    $groups = Get-MgUserMemberOf -UserId $User.Id -All | Where-Object {
        $_.AdditionalProperties.'@odata.type' -eq '#microsoft.graph.group'
    }
    foreach ($g in $groups) {
        try {
            Remove-MgGroupMemberByRef -GroupId $g.Id -DirectoryObjectId $User.Id
            Write-Host ("  removed from {0}" -f $g.AdditionalProperties.displayName)
        }
        catch {
            Write-Warning ("  could not leave {0}: {1}" -f $g.AdditionalProperties.displayName, $_.Exception.Message)
        }
    }

    # Drop all assigned licences
    $assigned = (Get-MgUser -UserId $User.Id -Property AssignedLicenses).AssignedLicenses
    if ($assigned) {
        Set-MgUserLicense -UserId $User.Id -AddLicenses @() -RemoveLicenses $assigned.SkuId
        Write-Host ("  released {0} licence(s)" -f $assigned.Count)
    }

    # OneDrive ownership transfer to the manager
    $tenantHost = (Get-MgOrganization).VerifiedDomains[0].Name -replace '\..*$',''
    $adminUrl   = "https://$tenantHost-admin.sharepoint.com"
    Connect-SPOService -Url $adminUrl -ErrorAction SilentlyContinue
    $oneDriveUrl = "https://$tenantHost-my.sharepoint.com/personal/$($User.UserPrincipalName -replace '[@.]','_')"
    try {
        Set-SPOUser -Site $oneDriveUrl -LoginName $ManagerUpn -IsSiteCollectionAdmin $true | Out-Null
        Write-Host ("  OneDrive ownership granted to {0}" -f $ManagerUpn)
    } catch {
        Write-Warning ("  OneDrive transfer skipped: {0}" -f $_.Exception.Message)
    }

    [pscustomobject]@{
        Upn        = $User.UserPrincipalName
        Manager    = $ManagerUpn
        Offboarded = (Get-Date).ToString('s')
        Groups     = $groups.Count
        Licences   = ($assigned | Measure-Object).Count
    }
}

The OneDrive ownership grant gives the manager 30 days to copy anything they need before the personal site is deleted automatically. If you want to extend that window, also set the OneDrive retention period in the SharePoint admin centre to ninety days; this is a tenant-wide setting, not a per-user one.

Putting it all together

Drive the three functions from a single dispatcher that takes a leaver UPN, looks up the manager from Entra ID, and calls each step in order. Schedule it from an Azure Automation runbook triggered by an HR webhook, or run it manually from a service-desk ticket form. Pipe the audit object to a CSV that gets attached to the HR offboarding ticket; that single row has every detail security or auditors will ask for. Keep the user object itself for thirty days as a tombstone, then let your stale-device and stale-user cleanup pipelines harvest it.

Closing thoughts

Offboarding is the most boring and most important script most IT teams never finish writing. The interesting failure modes are subtle: a missed group leaves shared content visible, a missed token lets a session refresh for hours, a missed licence keeps charging the bill. The script does not have to be elegant, it just has to be the same every time and produce a paper trail. Once it runs reliably, you stop thinking about leavers altogether, which is exactly the right amount of attention to give them.