
Why Your M365 Tenant Is a Mess (And How to Fix It)
Microsoft 365 tenant health audit checklist: detect orphaned groups, expired app secrets, CA policy sprawl, and SharePoint chaos with Graph API scripts.
Every Microsoft 365 tenant I inherit has the same problems. The symptoms vary in severity, but the pattern is remarkably consistent: groups that nobody owns, SharePoint sites that nobody maintains, app registrations with secrets that expired six months ago, and a Conditional Access policy set that reads like geological strata from successive security audits.
These are not skills failures. The people who built and maintained these tenants were perfectly competent. The problem is that M365 tenants drift towards chaos by default. Every time someone creates a Teams channel, spins up a SharePoint site, or registers an application, the tenant gets a little messier. Without deliberate, automated governance, entropy wins.
This post is a tenant health assessment checklist. For each anti-pattern, I will show you how to detect it with Graph API, give you a sense of scale so you know whether your numbers are normal, and explain what to do about it. If you are looking for the broader case for standardisation and repeatable baselines, the Standardisation post covers that ground. This post is the diagnostic that tells you where to start.
Prerequisites: All scripts use the Microsoft Graph PowerShell SDK v2.x and require PowerShell 7+. Connect with the scopes you need before running each section. The snippets below are kept concise for readability; for production-ready Graph API patterns with proper error handling and parameterisation, see Microsoft Graph API for Architects.
Quick Reference: Tenant Health Metrics
| Issue | Typical Scale (2,000-user tenant) | Detection Method |
|---|---|---|
| Ownerless Groups | 150-300 groups | Get-MgGroup + Get-MgGroupOwner |
| Dormant SharePoint Sites | 20-40% of all sites | SharePoint usage reports via Graph |
| Expired App Credentials | 30-50% of app registrations | Get-MgApplication + PasswordCredentials |
| Non-Compliant Naming | 80%+ without enforced policy | Regex pattern match |
| Redundant CA Policies | 10-30% overlap | Get-MgIdentityConditionalAccessPolicy |
Required permissions: Group.Read.All, Application.Read.All, Policy.Read.All, Reports.Read.All
1. Orphaned M365 Groups
Microsoft 365 groups accumulate faster than anyone expects. Every Teams team creates one. Every Planner board creates one. Every Yammer community creates one. People create groups freely, then leave the organisation without transferring ownership. The group persists, ownerless, with members who have no idea they are in it and content that may or may not still be relevant.
In a 2,000-user tenant, expect 150 to 300 ownerless groups. Tenants that have been running for five years or more without governance are worse. I have seen 10,000-user tenants with 3,000+ groups where fewer than half had an identifiable owner.
Detecting Ownerless Groups
Connect-MgGraph -Scopes "Group.Read.All"
# Get all Microsoft 365 groups
$groups = Get-MgGroup -Filter "groupTypes/any(g:g eq 'Unified')" -All `
-Property Id,DisplayName,CreatedDateTime,Description
$ownerlessGroups = foreach ($group in $groups) {
$owners = Get-MgGroupOwner -GroupId $group.Id
if ($owners.Count -eq 0) {
[PSCustomObject]@{
DisplayName = $group.DisplayName
Created = $group.CreatedDateTime
Description = $group.Description
}
}
}
Write-Output "Ownerless groups: $($ownerlessGroups.Count) of $($groups.Count) total"
$ownerlessGroups | Sort-Object Created | Format-Table -AutoSizeFinding Stale Groups
Ownerless is bad. Ownerless and inactive is a cleanup candidate.
# M365 Groups activity report (requires Reports.Read.All)
$uri = "https://graph.microsoft.com/v1.0/reports/getOffice365GroupsActivityDetail(period='D90')"
Invoke-MgGraphRequest -Uri $uri -OutputFilePath "$env:TEMP\GroupActivity.csv"
$report = Import-Csv "$env:TEMP\GroupActivity.csv"
$staleGroups = $report | Where-Object {
$_.'Last Activity Date' -eq '' -or
([datetime]$_.'Last Activity Date') -lt (Get-Date).AddDays(-90)
}
Write-Output "Groups with no activity in 90 days: $($staleGroups.Count)"What to Do About It
Short term, assign owners to critical groups manually. For the rest, contact the last known owner or the most active member and ask them to take ownership or confirm the group can be deleted.
Long term, enable the M365 group expiration policy in Entra ID (Groups > Expiration). Set a lifecycle of 180 or 365 days. Groups without owners receive renewal reminders sent to configured fallback admins, and groups with no activity can be auto-deleted after the expiration period plus a 30-day grace. This requires Entra ID P1 or higher.
You should also restrict who can create M365 groups. By default, every licensed user can create them, which is how you end up with hundreds of orphans. Limit creation to a security group of people who understand the naming conventions and governance expectations. Configure this via the GroupCreationAllowedGroupId directory setting.
2. SharePoint Site Sprawl and Dormant Sites
Every M365 group gets a SharePoint site. Every Teams team gets one. People also create standalone communication sites and classic sites. Nobody tracks them centrally. You end up with "Marketing", "Marketing Team", "Marketing-old", "Marketing 2024", and "Marketing (DO NOT USE)".
A 2,000-user tenant commonly has 500 to 1,500 SharePoint sites. Of those, 20-40% typically have no identifiable owner or the listed owner has left the organisation.
Detecting Dormant Sites
Connect-MgGraph -Scopes "Reports.Read.All"
$uri = "https://graph.microsoft.com/v1.0/reports/getSharePointSiteUsageDetail(period='D90')"
Invoke-MgGraphRequest -Uri $uri -OutputFilePath "$env:TEMP\SharePointUsage.csv"
$sites = Import-Csv "$env:TEMP\SharePointUsage.csv"
$dormantSites = $sites | Where-Object {
$_.'Last Activity Date' -eq '' -or
([datetime]$_.'Last Activity Date') -lt (Get-Date).AddDays(-90)
}
Write-Output "Dormant sites (no activity in 90 days): $($dormantSites.Count) of $($sites.Count)"
# Identify potential duplicates by normalised name
$siteNames = $sites | ForEach-Object { ($_.'Site URL' -split '/')[-1] }
$potentialDuplicates = $siteNames |
Group-Object { $_ -replace '[-_\d]','' } |
Where-Object { $_.Count -gt 1 }
Write-Output "`nPotential duplicate site groups:"
$potentialDuplicates | ForEach-Object {
Write-Output " $($_.Name): $($_.Group -join ', ')"
}What to Do About It
Reassign ownership for sites where the listed owner has left. This is non-negotiable. A site without an owner is a site without accountability.
Implement an inactive site policy via the SharePoint admin centre. SharePoint now supports native inactive site policies that automatically notify owners when their site has been dormant and can restrict access after a defined period. This is far more practical than manually reviewing hundreds of sites quarterly.
Apply a naming convention. More on this in section 4, but the fix for "Marketing (DO NOT USE)" is a naming standard enforced at creation time, not hoped for after the fact.
3. Entra App Registrations with Expired Secrets
This one is insidious because it fails silently. A developer creates an app registration, adds a client secret with a two-year expiry, and moves on. The integration works perfectly for 23 months. Then the secret expires on a Saturday night and an automated process stops working. Nobody knows who created the app, what it does, or where the secret is used.
A 2,000-user tenant typically has 50 to 200 app registrations. In my experience, 30-50% have expired credentials and 20-40% have no assigned owner.
The Credential Audit
Connect-MgGraph -Scopes "Application.Read.All"
$apps = Get-MgApplication -All `
-Property Id,AppId,DisplayName,PasswordCredentials,KeyCredentials
$now = Get-Date
$warningThreshold = $now.AddDays(30)
$credentialReport = foreach ($app in $apps) {
$owners = Get-MgApplicationOwner -ApplicationId $app.Id
$ownerNames = if ($owners) {
($owners.AdditionalProperties.displayName -join '; ')
} else { "NO OWNER" }
foreach ($secret in $app.PasswordCredentials) {
[PSCustomObject]@{
AppName = $app.DisplayName
AppId = $app.AppId
Type = "Secret"
Expires = $secret.EndDateTime
Status = if ($secret.EndDateTime -lt $now) { "EXPIRED" }
elseif ($secret.EndDateTime -lt $warningThreshold) { "EXPIRING SOON" }
else { "OK" }
Owners = $ownerNames
}
}
foreach ($cert in $app.KeyCredentials) {
[PSCustomObject]@{
AppName = $app.DisplayName
AppId = $app.AppId
Type = "Certificate"
Expires = $cert.EndDateTime
Status = if ($cert.EndDateTime -lt $now) { "EXPIRED" }
elseif ($cert.EndDateTime -lt $warningThreshold) { "EXPIRING SOON" }
else { "OK" }
Owners = $ownerNames
}
}
}
$expired = ($credentialReport | Where-Object Status -eq "EXPIRED").Count
$expiringSoon = ($credentialReport | Where-Object Status -eq "EXPIRING SOON").Count
$noOwner = ($credentialReport | Where-Object Owners -eq "NO OWNER" |
Select-Object AppName -Unique).Count
Write-Output "Expired credentials: $expired"
Write-Output "Expiring within 30 days: $expiringSoon"
Write-Output "Apps with no owner: $noOwner"What to Do About It
Assign an owner to every app registration. Entra ID sends expiration notifications to app owners at 60, 30, 15, and 7 days before credential expiry, but only if an owner is listed. No owner means no warning.
Prefer certificates over client secrets. Certificates are harder to leak (they cannot be copied from the portal), support longer validity, and align better with zero-trust principles.
Prefer managed identities where possible. If the workload runs in Azure (Logic Apps, Azure Functions, Automation Accounts), use a managed identity instead of an app registration. No secrets to manage, no expiry to monitor, no credentials to rotate. This is the single most effective way to reduce app registration sprawl.
Enforce tenant-wide credential policies. The application authentication methods policy lets you restrict secret lifetime across the tenant, block password credentials entirely, and force certificates. If you cannot justify a blanket restriction, at minimum set a maximum secret lifetime of 180 days so that the longest possible gap between "secret created" and "secret expires with nobody watching" is six months rather than two years.
4. Naming Convention Chaos
Without an enforced naming policy, you get Teams called "Project Alpha", groups called "prj-alpha-team", and SharePoint sites called "Alpha Project Site". Search becomes guesswork. Departments create duplicate resources because they cannot find existing ones.
This is almost universal. In tenants without enforced naming policies, expect inconsistent naming in 80%+ of groups, sites, and Teams.
Measuring Compliance
Connect-MgGraph -Scopes "Group.Read.All"
# Define your expected naming pattern
# Example: department prefix like "IT-", "HR-", "FIN-"
$namingPattern = '^(IT|HR|FIN|MKT|ENG|OPS|SEC|LEGAL)-'
$groups = Get-MgGroup -Filter "groupTypes/any(g:g eq 'Unified')" -All `
-Property DisplayName,CreatedDateTime
$compliant = ($groups | Where-Object { $_.DisplayName -match $namingPattern }).Count
$total = $groups.Count
Write-Output "Naming compliance: $compliant/$total ($([math]::Round($compliant/$total*100,1))%)"What to Do About It
Enable the Entra ID group naming policy (requires P1). This enforces prefixes and suffixes automatically at creation time. You can use attribute-based prefixes (e.g., [Department]-[GroupName]) and block offensive or reserved words. Configure it in the Entra admin centre under Groups > Naming policy.
The important caveat: the naming policy only applies to groups created after the policy is enabled. Existing groups need to be renamed manually or via script. This is a one-time effort that nobody enjoys, but it is worth doing. A tenant where every group follows a consistent naming convention is dramatically easier to administer than one where you have to guess what "Project Phoenix" refers to.
5. Conditional Access Policy Sprawl
This is the most dangerous anti-pattern on the list. Organisations accumulate Conditional Access policies over years. Each security incident or compliance audit produces a new policy. Nobody removes old ones. Policies overlap, contradict each other, and interact in unexpected ways. The "What If" tool in Entra ID exists specifically because nobody can reason about their CA policy set by reading the policies alone.
A 2,000-user tenant typically has 15 to 40 CA policies. Tenants that have been through multiple security audits can have 60+. Of those, 10-30% are often redundant or superseded by newer policies.
For a deeper dive into building a CA policy set from scratch (including the persona-based framework, break-glass accounts, and the evaluation flow), see the Conditional Access post. This section focuses on auditing what you already have.
The CA Audit
Connect-MgGraph -Scopes "Policy.Read.All"
$policies = Get-MgIdentityConditionalAccessPolicy -All
$caReport = foreach ($policy in $policies) {
[PSCustomObject]@{
Name = $policy.DisplayName
State = $policy.State
Modified = $policy.ModifiedDateTime
IncludeUsers = ($policy.Conditions.Users.IncludeUsers -join '; ')
IncludeApps = ($policy.Conditions.Applications.IncludeApplications -join '; ')
GrantControls = ($policy.GrantControls.BuiltInControls -join '; ')
}
}
$enabled = ($caReport | Where-Object State -eq 'enabled').Count
$reportOnly = ($caReport | Where-Object State -eq 'enabledForReportingButNotEnforced').Count
$disabled = ($caReport | Where-Object State -eq 'disabled').Count
Write-Output "CA policies: $($policies.Count) total"
Write-Output " Enabled: $enabled"
Write-Output " Report-only: $reportOnly"
Write-Output " Disabled: $disabled (review these for removal)"
# Flag policies not modified in over a year
$staleDate = (Get-Date).AddYears(-1)
$stalePolicies = $caReport | Where-Object {
$_.Modified -and ([datetime]$_.Modified) -lt $staleDate
}
Write-Output "`nPolicies not modified in over 1 year: $($stalePolicies.Count)"
$stalePolicies | Select-Object Name, State, Modified | Format-Table -AutoSizeAuditing Named Locations
Named locations are the forgotten dependency. Policies reference them, but nobody maintains the IP ranges. People leave, offices close, VPN endpoints change, and the named location still says "London Office" with a CIDR block that now belongs to a different tenant.
# Find named locations not used by any policy
$locations = Get-MgIdentityConditionalAccessNamedLocation -All
$unusedLocations = foreach ($loc in $locations) {
$usedBy = $policies | Where-Object {
$_.Conditions.Locations.IncludeLocations -contains $loc.Id -or
$_.Conditions.Locations.ExcludeLocations -contains $loc.Id
}
if ($usedBy.Count -eq 0) {
[PSCustomObject]@{
Name = $loc.DisplayName
Type = $loc.AdditionalProperties.'@odata.type' -replace '#microsoft.graph.',''
}
}
}
Write-Output "Named locations not referenced by any policy: $($unusedLocations.Count)"
$unusedLocations | Format-Table -AutoSizeWhat to Do About It
Export and map everything. Get all policies into a spreadsheet, document what each one does and why it exists, and identify overlaps. Use the What If tool to test common scenarios: what happens when a standard user signs in from a managed device? From a personal device? From overseas?
Adopt a persona-based framework. Define four to six user personas (standard users, privileged admins, guests, service accounts, break-glass) and ensure each has a clear, documented policy set. If you cannot explain which policies apply to a given persona without running What If, your policy set is too complex.
Remove disabled policies. A disabled CA policy is not a safety net. It is clutter that makes the active policy set harder to understand. If you disabled it for a reason, document that reason and delete the policy. If you might need it again, export the JSON first.
Verify break-glass exclusions. At least two break-glass accounts should be excluded from every CA policy. These should be cloud-only, use FIDO2 keys stored physically in a safe, and be monitored with alerts on any sign-in. If even one CA policy does not exclude your break-glass accounts, you have a lockout risk.
6. Security Defaults vs. Custom CA Confusion
This one catches more tenants than you would expect. Security defaults provide baseline MFA and block legacy authentication. They are designed for organisations without Entra ID P1 or P2. Custom Conditional Access policies require P1. The two are mutually exclusive: you cannot have both enabled simultaneously.
The problem is not having both enabled (Microsoft blocks this). The problem is disabling security defaults to implement CA policies and then failing to replicate the protections that security defaults provided.
Checking for Gaps
Connect-MgGraph -Scopes "Policy.Read.All"
$secDefaults = Invoke-MgGraphRequest `
-Uri "https://graph.microsoft.com/v1.0/policies/identitySecurityDefaultsEnforcementPolicy"
Write-Output "Security Defaults enabled: $($secDefaults.isEnabled)"
if (-not $secDefaults.isEnabled) {
$policies = Get-MgIdentityConditionalAccessPolicy -All |
Where-Object { $_.State -eq 'enabled' }
$mfaForAll = $policies | Where-Object {
$_.Conditions.Users.IncludeUsers -contains 'All' -and
$_.GrantControls.BuiltInControls -contains 'mfa'
}
$blockLegacy = $policies | Where-Object {
($_.Conditions.ClientAppTypes -contains 'exchangeActiveSyncClients' -or
$_.Conditions.ClientAppTypes -contains 'other') -and
$_.GrantControls.BuiltInControls -contains 'block'
}
$gaps = @()
if (-not $mfaForAll) { $gaps += "No policy requiring MFA for all users" }
if (-not $blockLegacy) { $gaps += "No policy blocking legacy authentication" }
if ($gaps.Count -gt 0) {
Write-Warning "Security Defaults are OFF but CA policies have gaps:"
$gaps | ForEach-Object { Write-Warning " - $_" }
} else {
Write-Output "CA policies appear to cover Security Defaults protections."
}
}What to Do About It
If you have Entra ID P1 or P2, disable security defaults and implement CA policies that cover the protections security defaults provided: MFA registration for all users, risk-based MFA enforcement, require MFA for admin roles, block legacy authentication, and protect Azure management endpoints.
If you do not have P1 or P2, keep security defaults enabled and do not try to use CA policies.
Either way, document the decision. This is a common audit finding, and assessors want to see that the choice was deliberate rather than accidental.
The Governance Stack That Prevents All of This
Detecting these problems is the easy part. Preventing them from recurring is what separates a one-off audit from actual governance.
The good news is that Microsoft has shipped most of the tooling you need in the last two years. The bad news is that none of it is enabled by default.
Group Lifecycle
- Expiration policy: Auto-delete groups with no owner and no activity after a configurable period (180 or 365 days). Requires Entra ID P1.
- Creation restrictions: Limit M365 group creation to a security group. Stops the unchecked proliferation at source.
- Naming policy: Enforce department prefixes and blocked words. Requires P1.
- Access reviews: Periodically prompt group owners to confirm memberships are still correct. Supports multi-stage escalation if the owner does not respond. Requires Entra ID Governance (included in P2 or available as a standalone licence).
Application Governance
- Authentication methods policy: Restrict secret lifetime, block password credentials, force certificates.
- Owner assignment enforcement: Make it a policy that no app registration ships without at least two owners. Entra sends expiry notifications to owners automatically.
- Workload identity recommendations: Part of Entra Workload ID Premium (separate licence from Entra ID P1/P2). Surfaces unused apps, overprivileged service principals, and credential expiry warnings.
- Managed identities: Use them for every Azure-hosted workload. No secrets, no expiry, no rotation.
Conditional Access Hygiene
- Authentication strengths: Replace the blunt "require MFA" grant control with fine-grained requirements (phishing-resistant only, passwordless only). Now generally available; custom strengths require Entra ID P1.
- Policy templates: Pre-built policies for common scenarios (block legacy auth, require MFA for admins). Use them as a starting point rather than building from scratch.
- Regular review cadence: Review your CA policy set quarterly. If a policy has been in report-only mode for more than three months, either promote it to enforced or delete it.
Running the Full Audit
Each section above stands alone, but the real value is running all the checks together and producing a single tenant health report. The workflow is straightforward:
- Create an Entra ID app registration with the read-only permissions from each section (
Group.Read.All,Application.Read.All,Policy.Read.All,Reports.Read.All). - Run each detection script in sequence.
- Export everything to a single workbook with one sheet per anti-pattern.
- Present the findings with estimated impact: number of ownerless groups, dormant sites, expired credentials, non-compliant names, and CA policy gaps.
The numbers speak for themselves. In my experience, the first audit always surfaces enough issues to justify the time it took to run, and usually several times over. The Licensing Audit post covers the financial side (orphaned licences, E5-to-E3 downgrade candidates). This post covers the structural side. Together, they give you a comprehensive picture of tenant health.
The hard part is not the audit. The hard part is getting the governance stack implemented and keeping it maintained. That requires executive sponsorship, documented standards, and automation that runs whether anyone remembers to check or not. M365 tenants drift towards chaos by default. Governance is the counterforce.
If you have inherited a particularly messy tenant and found creative ways to clean it up, I would be interested to hear what worked. The scripts are the easy part; the organisational change management is where the real war stories live.