Microsoft Graph PowerShell: Bulk Disable Inactive Mailboxes
─□✕

Microsoft Graph PowerShell: Bulk Disable Inactive Mailboxes

PowerShell Tips Editor 5 min read

The MSOnline and AzureAD PowerShell modules are deprecated and headed for retirement. If you’re managing Exchange Online mailbox lifecycle, the microsoft graph powershell bulk disable inactive mailboxes workflow using the Microsoft Graph SDK is the supported replacement. This guide shows you how to find accounts with no sign-in activity for 90 or more days, optionally convert their mailboxes to shared, then bulk-disable the accounts — all with a full pre- and post-action audit trail.

Installing and Connecting Microsoft.Graph PowerShell SDK

Install the SDK from the PowerShell Gallery and connect with the scopes you need. For bulk disable operations you need at minimum User.ReadWrite.All and AuditLog.Read.All. Use -Scopes for delegated (interactive) auth or a certificate/secret for application auth in automation.

# Install if not present
if (-not (Get-Module Microsoft.Graph -ListAvailable)) {
    Install-Module Microsoft.Graph -Scope CurrentUser -Force
}

# Connect with required scopes (delegated, browser consent)
Connect-MgGraph -Scopes 'User.ReadWrite.All', 'AuditLog.Read.All', 'Mail.ReadWrite'

# Verify connection
$ctx = Get-MgContext
Write-Host "Connected as $($ctx.Account) to tenant $($ctx.TenantId)"

For unattended automation, replace the -Scopes parameter with -TenantId, -ClientId, and -CertificateThumbprint (or -ClientSecretCredential) to authenticate as an app registration.

Finding Users with No Sign-In for 90 Days via Get-MgUser

The signInActivity property on the user object holds lastSignInDateTime. You must request it explicitly via -Property because Graph does not return it by default.

$cutoff = (Get-Date).AddDays(-90).ToUniversalTime().ToString('o')

# Get licensed users with sign-in activity data
$inactiveUsers = Get-MgUser -All `
    -Filter "accountEnabled eq true and userType eq 'Member'" `
    -Property 'id,displayName,userPrincipalName,assignedLicenses,signInActivity' |
    Where-Object {
        # Exclude accounts that never signed in from the null-safe check
        $lastSignIn = $_.SignInActivity?.LastSignInDateTime
        $lastSignIn -ne $null -and [datetime]$lastSignIn -lt (Get-Date).AddDays(-90)
    }

Write-Host "Inactive licensed users (90+ days): $($inactiveUsers.Count)"

Accounts with a null LastSignInDateTime may be brand new or may have never signed in. Handle them separately — do not silently skip them in a production disable run.

Correlating Sign-In Data with Mailbox Last Activity

Sign-in activity alone does not confirm that a mailbox is inactive — users may access mail through shared devices or mobile profiles that authenticate differently. Add an Exchange Online check using Get-EXOMailboxStatistics from the ExchangeOnlineManagement module.

Import-Module ExchangeOnlineManagement
Connect-ExchangeOnline -ShowBanner:$false

$enriched = foreach ($user in $inactiveUsers) {
    $stats = Get-EXOMailboxStatistics -Identity $user.UserPrincipalName `
                 -Properties LastLogonTime -ErrorAction SilentlyContinue

    [PSCustomObject]@{
        UPN              = $user.UserPrincipalName
        DisplayName      = $user.DisplayName
        LastAADSignIn    = $user.SignInActivity?.LastSignInDateTime
        LastMailboxLogon = $stats?.LastLogonTime
        HasLicense       = ($user.AssignedLicenses.Count -gt 0)
    }
}

# Only proceed with accounts where both signals confirm inactivity
$confirmed = $enriched | Where-Object {
    $_.LastMailboxLogon -eq $null -or
    ([datetime]$_.LastMailboxLogon) -lt (Get-Date).AddDays(-90)
}

Bulk Disabling Accounts with Update-MgUser

Once you have a confirmed list, disable in bulk with a -WhatIf-equivalent dry run first. The Graph SDK does not natively support -WhatIf, so use a flag variable.

$WhatIf = $true   # Set to $false to actually disable

foreach ($user in $confirmed) {
    if ($WhatIf) {
        Write-Host "[WHATIF] Would disable: $($user.UPN)"
    }
    else {
        try {
            Update-MgUser -UserId $user.UPN -AccountEnabled $false -ErrorAction Stop
            Write-Host "Disabled: $($user.UPN)"
        }
        catch {
            Write-Warning "Failed to disable $($user.UPN): $_"
        }
    }
}

Converting Mailboxes to Shared Before Disabling

Disabling a licensed user account does not automatically remove their license or preserve mail access for legal hold. Convert to a shared mailbox first so colleagues can access historical mail and you can remove the paid license.

foreach ($user in $confirmed) {
    try {
        Set-Mailbox -Identity $user.UPN -Type Shared -ErrorAction Stop
        Write-Host "Converted to Shared: $($user.UPN)"

        # Remove assigned licenses via Graph
        $licenseUpdate = @{ AddLicenses = @(); RemoveLicenses = ($user | Get-MgUserLicenseDetail | Select-Object -ExpandProperty SkuId) }
        Set-MgUserLicense -UserId $user.UPN -BodyParameter $licenseUpdate
    }
    catch {
        Write-Warning "Mailbox conversion failed for $($user.UPN): $_"
    }
}

Generating a Pre- and Post-Action Audit Report

Export the full list of affected accounts before and after the operation for change-management records.

$date = Get-Date -Format 'yyyy-MM-dd'

# Pre-action snapshot
$confirmed | Export-Csv "C:\Audit\InactiveMailboxes-PRE-$date.csv" -NoTypeInformation -Encoding UTF8

# Post-action verification
$postCheck = foreach ($user in $confirmed) {
    $u = Get-MgUser -UserId $user.UPN -Property 'accountEnabled,userPrincipalName' -ErrorAction SilentlyContinue
    [PSCustomObject]@{
        UPN            = $user.UPN
        AccountEnabled = $u?.AccountEnabled
        DisabledAt     = $date
    }
}

$postCheck | Export-Csv "C:\Audit\InactiveMailboxes-POST-$date.csv" -NoTypeInformation -Encoding UTF8
Write-Host "Audit reports saved."

Common Errors and Fixes

Insufficient Graph API permissions — missing User.ReadWrite.All scope. If you see “Insufficient privileges to complete the operation”, disconnect and reconnect with the correct scopes: Disconnect-MgGraph then Connect-MgGraph -Scopes 'User.ReadWrite.All','AuditLog.Read.All'. For app-only auth, verify the app registration has the required Application (not Delegated) permissions and that admin consent has been granted in Entra ID.

LastSignInDateTime is null for accounts that never signed in. Use the null-conditional operator $_.SignInActivity?.LastSignInDateTime to avoid “Cannot index into a null array” errors. Track never-signed-in accounts separately — they may be service accounts or onboarding accounts that should be excluded from the disable run.

Related Cmdlets / See Also

Wrapping Up

Migrating mailbox lifecycle management from MSOnline to the Graph SDK is straightforward once you understand the permission model and the signInActivity property quirks. Pair sign-in data with Exchange mailbox statistics for confident inactive-user detection, always run a dry pass first, and keep pre/post audit CSVs for every change window.

Send-Item -To