Get-SPOOrphanedSiteRisk
This PnP PowerShell script audits every site in a SharePoint Online tenant for ownership risk — sites with zero owners, and sites with exactly one owner who could leave the organisation with nobody left to manage access. It also counts lists and libraries with broken permission inheritance on each site as a secondary sprawl indicator. Results are exported to a CSV, ranked by risk level, so the sites needing attention first are at the top. Background on why this matters is covered in The SharePoint Sites Nobody Owns.
Purpose
Owner accounts get disabled during routine offboarding without anyone checking whether that person was the last active owner of a site. This script surfaces that risk at tenant scale by:
- Enumerating every site in the tenant via
Get-PnPTenantSite, excluding OneDrive personal sites - Connecting to each site and reading its Owners group membership
- Counting lists and libraries with broken (unique) permission inheritance as a sprawl indicator
- Classifying each site as CRITICAL (no owners), HIGH (single owner), or OK (two or more owners)
- Skipping known system-managed sites — App Catalog, Search Center, MySite Host, and the tenant admin site — which don't expose a standard owners group
- Exporting a risk-ranked CSV for follow-up
Scope
- OneDrive personal sites (
-my.sharepoint.com) are excluded — ownership works differently there and isn't a governance gap in the same sense - Owner counts come from the site's Associated Owner Group membership, not the Site Collection Administrators list — the two can differ
- The script reports whether accounts are present in the owners group; cross-referencing against Entra ID account status (enabled vs disabled) is a natural follow-up if you need to catch the "owners on paper, disabled in practice" case described in the accompanying post
Prerequisites
- PnP.PowerShell 3.x module installed
- SharePoint Administrator or Global Administrator permissions
- An Azure AD app registration with the required permissions, or use interactive login
PowerShell Script
# ============================================================
# Get-SPOOrphanedSiteRisk.ps1
# Tenant-wide audit for SharePoint Online sites at risk of
# "orphaned" ownership: sites with zero or one owner. Also
# reports a broken-inheritance count per site as a secondary
# governance signal.
# ============================================================
<#
.SYNOPSIS
Tenant-wide audit for SharePoint Online sites at risk of "orphaned" ownership:
sites with zero or one owner. Also reports a broken-inheritance count per site
as a secondary governance signal.
.NOTES
Requires: PnP.PowerShell 3.x
Author: Cameron Griffiths
Tenant: tenantName.onmicrosoft.com (adjust -AdminUrl below for other tenants)
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$AdminUrl,
[Parameter(Mandatory = $false)]
[string]$ClientId = "",
[Parameter(Mandatory = $false)]
[string]$OutputPath = ".\OrphanedSiteRisk_$(Get-Date -Format 'yyyyMMdd_HHmmss').csv",
[Parameter(Mandatory = $false)]
[int]$ThrottleDelayMs = 300
)
# --- Known system-managed site templates to skip ---
# These don't expose a standard AssociatedOwnerGroup and aren't meaningful
# for an ownership-risk audit.
$systemTemplates = @(
"APPCATALOG#0", # App Catalog
"SRCHCEN#0", # Search Center
"SPSMSITEHOST#0", # MySite Host
"SPSTOC#0", # Content Type Hub / Compliance Policy Center variants
"TENANTADMIN#0" # Tenant admin site itself
)
# --- Connect to the SPO admin site ---
Write-Host "Connecting to $AdminUrl ..." -ForegroundColor Cyan
Connect-PnPOnline -Url $AdminUrl -Interactive -ClientId $ClientId
# --- Get all sites (excluding OneDrive personal sites) ---
Write-Host "Retrieving tenant site list ..." -ForegroundColor Cyan
$sites = Get-PnPTenantSite -Detailed | Where-Object {
$_.Template -notlike "SPSPERS*" -and $_.Url -notlike "*-my.sharepoint.com*"
}
Write-Host "Found $($sites.Count) sites to audit." -ForegroundColor Cyan
$report = New-Object System.Collections.Generic.List[Object]
$counter = 0
foreach ($site in $sites) {
$counter++
Write-Progress -Activity "Auditing sites" -Status $site.Url -PercentComplete (($counter / $sites.Count) * 100)
# --- Skip known system-managed sites ---
if ($systemTemplates -contains $site.Template) {
$report.Add([PSCustomObject]@{
SiteUrl = $site.Url
Title = $site.Title
TotalOwnersListed = "-"
OwnerNames = "-"
ListsWithUniquePerms = "-"
RiskLevel = "Skipped - System site"
LastContentModified = $site.LastContentModifiedDate
})
continue
}
try {
Connect-PnPOnline -Url $site.Url -Interactive -ClientId $ClientId -WarningAction SilentlyContinue
# --- Owner group membership ---
$ownerGroup = Get-PnPGroup -AssociatedOwnerGroup -ErrorAction Stop
$owners = Get-PnPGroupMember -Group $ownerGroup -ErrorAction SilentlyContinue |
Where-Object { $_.PrincipalType -eq "User" -and $_.LoginName -notlike "*spo-grid-all-users*" }
$ownerCount = $owners.Count
# --- Broken inheritance tally across lists/libraries ---
$lists = Get-PnPList -Includes HasUniqueRoleAssignments -ErrorAction SilentlyContinue
$uniquePermCount = ($lists | Where-Object { $_.HasUniqueRoleAssignments -eq $true -and -not $_.Hidden }).Count
# --- Risk classification ---
$risk = "OK"
if ($ownerCount -eq 0) {
$risk = "CRITICAL - No owners"
}
elseif ($ownerCount -eq 1) {
$risk = "HIGH - Single owner"
}
$report.Add([PSCustomObject]@{
SiteUrl = $site.Url
Title = $site.Title
TotalOwnersListed = $ownerCount
OwnerNames = ($owners.Title -join "; ")
ListsWithUniquePerms = $uniquePermCount
RiskLevel = $risk
LastContentModified = $site.LastContentModifiedDate
})
}
catch {
$report.Add([PSCustomObject]@{
SiteUrl = $site.Url
Title = $site.Title
TotalOwnersListed = "ERROR"
OwnerNames = ""
ListsWithUniquePerms = "ERROR"
RiskLevel = "UNKNOWN - Could not audit ($($_.Exception.Message))"
LastContentModified = $site.LastContentModifiedDate
})
}
Start-Sleep -Milliseconds $ThrottleDelayMs
}
Write-Progress -Activity "Auditing sites" -Completed
# --- Output ---
$report | Sort-Object RiskLevel -Descending | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8
Write-Host "`nAudit complete. Report written to $OutputPath" -ForegroundColor Green
$summary = $report | Group-Object RiskLevel | Select-Object Name, Count
Write-Host "`nRisk summary:" -ForegroundColor Yellow
$summary | Format-Table -AutoSize
Usage Notes
-AdminUrlis mandatory — pass your SharePoint admin centre URL, e.g.https://contoso-admin.sharepoint.com-ClientIdis your Azure AD app registration client ID, or leave blank to use interactive login without a registered app-OutputPathdefaults to a timestamped CSV in the current directory — override it to write to a shared audit location-ThrottleDelayMscontrols the pause between site connections (default 300ms) — increase it on larger tenants to avoid throttling- The script reconnects per site because owner group membership requires a site-level (not admin-level) connection context
- Sites the script can't audit — permission issues, deleted sites still listed, timeouts — are captured with a
RiskLevelofUNKNOWNrather than silently skipped, so nothing falls through the audit - The
$systemTemplatesarray can be extended if your tenant has other system-managed templates you want excluded from the report - Results are sorted by
RiskLevelso CRITICAL and HIGH rows surface near the top of the CSV, but re-sort byListsWithUniquePermsif you want to prioritise by sprawl instead
Related
- The SharePoint Sites Nobody Owns — Finding and Fixing Orphaned Site Risk — the governance reasoning behind this audit
- SharePoint Permissions — How to Control Who Sees What in Your Organisation — the permissions fundamentals this script builds on