Stop Paying for Users Who Left: Clean Up Inactive Microsoft 365 Accounts with PowerShell – MOUHIMEN.DEV

Stop Paying for Users Who Left: Clean Up Inactive Microsoft 365 Accounts with PowerShell

By Abd Almouhimen shamout | October 16, 2026

Stop Paying for Users Who Left: Clean Up Inactive Microsoft 365 Accounts

Watch the 1-minute video: 

Every account that nobody uses anymore still costs you a license. It’s also a security risk: nobody notices if an attacker signs in to an account its owner abandoned months ago. In schools it’s even worse, because every semester brings a new batch of students and the old batch never gets cleaned up.

In my bulk AD students video we created a whole semester of accounts in minutes. This is the other half of the lifecycle: finding the accounts nobody uses and cleaning them up safely.

The safe 3-stage plan

Don’t delete inactive users straight away. Do it in stages:

  1. Report: list licensed users with no successful sign-in for 90 days. Change nothing.
  2. Block and unlicense: block sign-in, sign them out everywhere, remove their licenses.
  3. Delete after 30 days: if nobody asked for the account back, delete it.

Microsoft’s own guidance says the same thing: there’s no built-in “inactive user” report, so you decide based on the last sign-in date, and you should allow for legitimate absences like long holidays or parental leave. Many organizations use a 90 to 180 day window.

What you need

  • PowerShell 7 and the Microsoft Graph PowerShell SDK: Install-Module Microsoft.Graph -Scope CurrentUser. The old MSOnline and AzureAD modules are retired, so scripts that use Get-MsolUser won’t work anymore.
  • Microsoft Entra ID P1 (or P2) in the tenant. The sign-in activity data needs it. P1 comes with Microsoft 365 Business Premium, E3, E5, A3 and A5. Business Basic, Business Standard and A1 don’t include it (you can add Entra ID P1 on its own).
  • An admin who can read sign-in reports and manage users and licenses (for example User Administrator + Reports Reader).
  • Hybrid tenants only: run the script on a server with the ActiveDirectory module (RSAT), for example your Entra Connect server.

Step 1: Connect to Microsoft Graph

Connect-MgGraph -Scopes "User.ReadWrite.All","AuditLog.Read.All","GroupMember.ReadWrite.All"

AuditLog.Read.All is what allows you to read sign-in activity. The other two let the script block users and change group membership.

Step 2: Find inactive users and export a report

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

$users = Get-MgUser -All -Filter "accountEnabled eq true and userType eq 'Member'" `
    -Property Id,DisplayName,UserPrincipalName,CreatedDateTime,SignInActivity,AssignedLicenses,LicenseAssignmentStates,OnPremisesSyncEnabled

$inactive = $users | Where-Object {
    $last = $_.SignInActivity.LastSuccessfulSignInDateTime
    $_.AssignedLicenses.Count -gt 0 -and
    ( ($last -and $last -lt $cutoff) -or (-not $last -and $_.CreatedDateTime -lt $cutoff) )
}

A few details that matter:

  • Use LastSuccessfulSignInDateTime. LastSignInDateTime also counts failed attempts, so an account that attackers keep trying to break into would look “active”. The successful sign-in value has existed since December 2023.
  • Never signed in (empty value) only counts as inactive if the account is older than the cutoff. Otherwise you’d block the new starters you created yesterday.
  • Only licensed users are included, because that’s where the money is. Guests have their own cleanup process.

Here’s what the report looks like:

DisplayNameUserPrincipalNameLastSuccessfulSignInSyncedFromAD
Abd Shamout (Spring 2026)s2026014@mouhimen.dev2026-05-28True
Abd A. Shamoutabd.temp@mouhimen.dev2026-06-30False
Mouhimen Dev Internintern.mouhimen@mouhimen.dev(never)False

Check it before you go further. Then add your emergency (“break-glass”) admin accounts and service accounts to exclude.txt, one UPN per line. Those accounts are supposed to sit unused, and locking them is the last thing you want.

Step 3: Block sign-in and free the licenses

Run the script again with -Apply:

.\Invoke-InactiveCleanup.ps1 -Apply

For each inactive user it:

  1. Blocks sign-in (Update-MgUser -AccountEnabled:$false)
  2. Signs them out everywhere (Revoke-MgUserSignInSession)
  3. Removes the licenses

Licenses are the second tricky part. If you assign licenses with groups (group-based licensing, like we did for the students), you can’t remove them from the user directly: Set-MgUserLicense only removes directly assigned licenses. The script removes the user from the licensing group instead (Remove-MgGroupMemberByRef).

Before you remove a license, save the data you need. When a license is removed, the mailbox and OneDrive are kept for a grace period and then deleted. Convert important mailboxes to a shared mailbox, give a manager access to OneDrive, or put the account on a retention policy first.

The tricky part: hybrid users synced from on-prem AD

If you use Entra Connect (or Cloud Sync), the user’s “real” account lives in your on-prem Active Directory. The cloud copy is read-only for most properties. If you try Update-MgUser -AccountEnabled:$false on a synced user, it fails, and even if it didn’t, the next sync would undo it.

So for synced users the script works on-prem:

Get-ADUser -Filter "UserPrincipalName -eq '$upn'" | Disable-ADAccount
Remove-ADGroupMember -Identity "Students-Spring2026" -Members $adUser -Confirm:$false

After the next sync cycle (every 30 minutes by default, or Start-ADSyncSyncCycle -PolicyType Delta on the Entra Connect server), the cloud account is blocked and the group-based license is gone.

Delete after 30 days

Wait 30 days. If nobody needed the account, delete it:

  • Cloud-only: Remove-MgUser -UserId <id> (it goes to Deleted users for another 30 days, so you can still restore it).
  • Synced: delete it in AD (Remove-ADUser) or move it to an OU you don’t sync.

Run it every month

Schedule the report on the 1st of every month with Task Scheduler or Azure Automation. For an unattended run, use an app registration with a certificate instead of your own sign-in:

Connect-MgGraph -ClientId "<app-id>" -TenantId "<tenant-id>" -CertificateThumbprint "<thumbprint>"

Give the app the application permissions User.ReadWrite.All, AuditLog.Read.All and GroupMember.ReadWrite.All. I recommend running the report automatically and keeping -Apply as a step you run yourself after you’ve checked the CSV.

The full script

Download Invoke-InactiveCleanup.ps1 and exclude.txt: Invoke-InactiveCleanup.ps1 (zip), exclude.txt, inactive-users-sample.csv

.\Invoke-InactiveCleanup.ps1                 # report only (safe, changes nothing)
.\Invoke-InactiveCleanup.ps1 -Days 120       # different threshold
.\Invoke-InactiveCleanup.ps1 -Apply          # block sign-in + remove licenses

Checklist

  • [ ] Microsoft Graph PowerShell installed (not MSOnline/AzureAD)
  • [ ] Entra ID P1 in the tenant
  • [ ] Break-glass admins and service accounts in exclude.txt
  • [ ] Report checked before running -Apply
  • [ ] Important mailboxes and OneDrive data saved
  • [ ] Synced users handled in on-prem AD
  • [ ] Deletion scheduled 30 days later
  • [ ] Monthly report scheduled

Sources


Watch the video:

Abd Almouhimen Shamout, Take away support: IT fixes, delivered in under two minutes. More at mouhimen.dev.

Abd Almouhimen Shamout

Full-Stack Web Developer with 16+ years of experience in designing, developing, and managing enterprise-level web systems and applications.
Skilled in both frontend and backend technologies with proven expertise in system integration, database management, and cloud services.
Successfully delivered solutions serving thousands of users in higher education and healthcare sectors. Recognized for strong problem-solving skills, leadership, and the ability to drive digital transformation projects.

Skills

nextjs
moodle
php
laravel
mysql
androidstudio
drupal
dot-net
bootstrap
mongodb
oracle
react