๐ฅ Recipe ยท User & Group Management
Audit 90-Day Inactive M365 Users and Build a Safe Deprovisioning Plan
Identify stale accounts with real activity data from Exchange, SharePoint, OneDrive, and Teams โ then phase deprovisioning with manager notification
Complexity
Intermediate
Impact
cost-savings + security + governance + compliance + license-optimization
Context
Why This Matters
Inactive user accounts are one of the highest-leverage cleanup targets in a Microsoft 365 tenant. Every stale account typically carries a paid license (often E3 or E5), a live mailbox, OneDrive storage, and potentially SharePoint site ownership. Beyond cost, they're a security liability: dormant accounts are prime targets for password spray and phishing-based takeover.
Running a 90-day inactivity audit is a standard governance control recommended by CIS, NIST, and Microsoft Secure Score. This recipe walks through the right way to build that audit โ not just by looking at Entra sign-in logs (which miss service-specific activity), but by cross-referencing the getOffice365ActiveUserDetail usage report that includes Exchange, SharePoint, OneDrive, and Teams last-activity dates.
The output is not just a list โ it's a phased deprovisioning plan with manager notifications, license removal recommendations, and safeguards against accidentally disabling executive or service accounts.
Expected Outcomes
After completing this recipe you will have:
- A CSV report of all users inactive for 90+ days with per-service last-activity dates (Exchange, SharePoint, OneDrive, Teams)
- License assignment details for each inactive user (critical for cost recovery)
- Manager name and email for each inactive user (for the notification workflow)
- A recommended action per user (review for license removal vs. account disable)
- A phased 4-week deprovisioning plan (notify โ review โ disable โ delicense)
- A flag for high-risk accounts (executives, service accounts, shared mailboxes) that need HR/manual review
Risks & Considerations
Watch out for these pitfalls:
- Anonymized reports: By default M365 usage reports show concealed names like
5C0DBAโฆ. You must temporarily setdisplayConcealedNames: falseviaPATCH /admin/reportSettingsto get real UPNs. Always re-enable anonymization after the audit to stay aligned with your privacy posture. - Don't rely on
signInActivityalone: It only tracks Entra interactive/non-interactive sign-ins. A user may be inactive in Entra logs but still have Exchange mail flow or OneDrive sync activity. Use the usage report for a true picture. - Service accounts and shared mailboxes will appear as inactive โ they're designed to never sign in interactively. Tag and exclude them before any disable action.
- Executive accounts (C-suite, board members) often show low activity but should never be auto-deprovisioned. Always escalate these to HR.
- Don't delete accounts directly. The safe sequence is: notify manager โ remove licenses โ disable sign-in โ convert mailbox to shared โ wait 30 days โ delete. Skipping the shared-mailbox step loses mail data permanently.
- Licensed but never signed in: These are often onboarding mistakes (license assigned before user started) or offboarding failures. Investigate before revoking.
- B2B guest users have different activity patterns and may not appear correctly in the usage report. Handle them in a separate audit.
Required Permissions
| Permission | Why It's Needed |
|---|---|
| Reports.Read.All | Required to call getOffice365ActiveUserDetail and related usage reports that provide per-service activity data |
| ReportSettings.ReadWrite.All | Required to temporarily disable report anonymization (displayConcealedNames) so actual UPNs and display names appear in reports |
| User.Read.All | Required to fetch per-user license assignments, accountEnabled status, department, and job title |
| Directory.Read.All | Required to read each user's manager relationship for the notification workflow |
| Organization.Read.All | Optional โ needed if you want to map license SKU GUIDs to friendly product names for the report |
The fastest way to get this done: just ask Dex. Copy the prompt below and paste it into your Dex conversation.
For IT Admins
Paste into Dex Pro
Manual walkthrough โ Microsoft 365 Admin Center
The Admin Center provides a built-in inactive user view, though it's less detailed than the API-driven approach.
Step 1 โ Disable report anonymization (one-time)
- Sign in to https://admin.microsoft.com as a Global Admin or Reports Reader
- Navigate to
- Uncheck Display concealed user, group, and site names in all reports
- Click Save. Reports may take up to 24 hours to refresh with real names.
Step 2 โ Pull the active users report
- Go to
- Open the Active users โ Microsoft 365 Services card
- Set the date range to Last 90 days
- Click Export to download the CSV
Step 3 โ Identify inactive users
- Open the CSV in Excel
- Filter
Is Deleted= FALSE - Filter
Last Activity Date= blank OR older than 90 days ago - Cross-reference with to confirm account is enabled and still licensed
Step 4 โ Check SharePoint ownership
- Navigate to
- For each inactive user, filter the Primary admin column by their UPN
- Document any sites where they're the sole owner โ these need reassignment before account disable
Step 5 โ Check mailbox size and activity
- Go to
- Search for each inactive user
- Review mailbox size and last logon date โ larger mailboxes warrant shared-mailbox conversion before disable
Step 6 โ Notify the manager
- Go to
- Click the user โ Account tab โ note the Manager field
- Send the manager an email asking whether the account should be retained, converted, or disabled
Step 7 โ Execute deprovisioning after confirmation
- Remove licenses:
- Block sign-in:
- Convert mailbox: Exchange admin center โ Mailbox โ Convert to shared mailbox
- Wait 30 days, then delete from
Step 8 โ Re-enable report anonymization
Return to and re-check the anonymization option.
Graph API recipe
The end-to-end flow uses four endpoints: read report settings, toggle anonymization, pull the usage report, and enrich each inactive user with license and manager data.
1. Check current report anonymization setting
GET https://graph.microsoft.com/v1.0/admin/reportSettings
// Response
{
"displayConcealedNames": true
}2. Disable anonymization (temporarily)
PATCH https://graph.microsoft.com/v1.0/admin/reportSettings
Content-Type: application/json
{
"displayConcealedNames": false
}
// Returns 204 No Content3. Pull the 90-day Office 365 activity report
GET https://graph.microsoft.com/v1.0/reports/getOffice365ActiveUserDetail(period='D90')
// Returns CSV. Key columns:
// User Principal Name, Display Name, Is Deleted,
// Last Activity Date, Exchange Last Activity Date,
// SharePoint Last Activity Date, OneDrive Last Activity Date,
// Teams Last Activity Date, Has Exchange License, ...4. For each inactive user, fetch license and manager
GET /v1.0/users/{upn}?$select=id,assignedLicenses,accountEnabled,department,jobTitle
GET /v1.0/users/{id}/manager?$select=displayName,userPrincipalName
// Returns 404 if no manager is assigned โ handle gracefully5. (Optional) Check SharePoint site ownership
GET /v1.0/sites?search=*&$select=id,displayName,webUrl
// For each site, check owners via:
GET /v1.0/sites/{site-id}/permissions6. Re-enable anonymization
PATCH https://graph.microsoft.com/v1.0/admin/reportSettings
{
"displayConcealedNames": true
}Deprovisioning actions (after manager approval)
// Remove a license
POST /v1.0/users/{upn}/assignLicense
{
"addLicenses": [],
"removeLicenses": ["{skuId}"]
}
// Block sign-in
PATCH /v1.0/users/{upn}
{
"accountEnabled": false
}
// Revoke all refresh tokens / sessions
POST /v1.0/users/{upn}/revokeSignInSessionsNote: Converting a mailbox to shared is only available via Exchange Online PowerShell (Set-Mailbox -Type Shared), not Graph.
PowerShell walkthrough
The script below uses Microsoft.Graph PowerShell SDK. It downloads the 90-day usage report, identifies inactive users, enriches each with license and manager details, and outputs a CSV with deprovisioning recommendations.
Prerequisites
# Install once
Install-Module Microsoft.Graph -Scope CurrentUser
# Connect with required scopes
Connect-MgGraph -Scopes "Reports.Read.All","ReportSettings.ReadWrite.All","User.Read.All","Directory.Read.All"What the script does
- Saves the current
displayConcealedNamessetting so it can be restored - Disables report anonymization if needed
- Calls
Get-MgReportOffice365ActiveUserDetail -Period D90and parses the CSV - Filters for users where the latest activity across all services is older than 90 days (or never)
- For each match, calls
Get-MgUserandGet-MgUserManagerto enrich with license count, manager, and department - Tags each row with a recommendation (
Review for license removal,Service account candidate,Executive โ escalate to HR, etc.) - Exports to CSV and restores the original anonymization setting
Running it
# Basic run โ outputs to current directory
.\Audit-InactiveM365Users.ps1
# Custom threshold and output path
.\Audit-InactiveM365Users.ps1 -InactiveDays 60 -OutputPath "C:\Audits\Q1-Inactive.csv"
# Dry run โ does not toggle report settings
.\Audit-InactiveM365Users.ps1 -SkipAnonymizationToggleThe script is idempotent โ safe to run on a schedule (e.g., monthly via Azure Automation or a scheduled task).
Next steps