Get-SPOSiteActivityAudit
This PnP PowerShell script audits every site in a SharePoint Online tenant for genuine user activity. It reads LastItemUserModifiedDate rather than LastContentModifiedDate or LastItemModifiedDate, since those two are known to be bumped by system activity (audit logging, Content Type Hub subscriptions, usage recalculation, permission changes, feature activation) rather than an actual human doing something. Results, including site owners and storage used, are sorted longest-idle first and exported to a CSV, to support outreach to site owners about sites that may be safe to archive or retire.
What This Script Can't Tell You
This script answers "when did content last change," not "when did a human last look at this." For the actual goal, contacting owners about sites that may be safe to archive, that distinction matters a lot. A site nobody's edited since 2024 but that people still regularly read, reference documents, policy libraries, archives people consult but don't update, is a very different conversation than a site genuinely abandoned and unvisited. The script alone can't tell those two cases apart; both show up as equally "Inactive 2+ years" even though one might still be actively useful to someone.
Given that, treat this script as the first filter, not the final word. For any site it flags, especially ones close to the 1-2 year boundary rather than obviously ancient, a quick manual check of the admin center's own Activity tab is worth doing before actually emailing an owner. If that check becomes routine enough to be annoying, that's the signal it's worth building a Graph usage-report cross-reference into a v2 of this script, automating the comparison properly.
Purpose
"Last modified" dates get inflated by system processes long after anyone stopped actually using a site, which makes it hard to tell genuinely dead sites from ones that are just quiet. This script surfaces real user activity at tenant scale by:
- Enumerating every site in the tenant via
Get-PnPTenantSite, excluding OneDrive personal sites - Reading each site's
LastItemUserModifiedDate, which reflects genuine user edits rather than system-triggered changes - Reading Owners group membership per site so outreach has someone to contact
- Recording storage used per site as additional context for the archive/retire decision
- Classifying each site as Active, Inactive 1-2 years, or Inactive 2+ years based on days since last genuine user activity
- Skipping known system-managed sites — App Catalog, Search Center, MySite Host, Content Type Hub, and the tenant admin site
- Exporting a CSV sorted longest-idle first, so the best archive candidates surface at the top
Scope
- OneDrive personal sites (
-my.sharepoint.com) are excluded — activity works differently there and isn't part of this audit - Owner counts come from the site's Associated Owner Group membership, not the Site Collection Administrators list — the two can differ
- Storage figures reflect
StorageUsageCurrentat the time the script runs, not a historical trend - The script can't distinguish a human editing content from an unattended Power Automate flow running under a named user's delegated connection — both register identically as a user modification. Treat "recently modified, but very low storage growth and a single owner who's long gone" as a hint worth double-checking, not an automatic sign of genuine activity
- Sites with no owners at all are called out in the run summary — worth cross-referencing against a site ownership-risk audit (see Related, below) before attempting outreach on those
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-SPOSiteActivityAudit.ps1
# Tenant-wide SharePoint Online site activity audit. Reports
# genuine last user activity, owners, and storage used, sorted
# longest-idle first, to support outreach about sites that may
# be safe to archive or retire.
# ============================================================
<#
.SYNOPSIS
Tenant-wide SharePoint Online site activity audit. Reports Name, URL,
last user activity, site owners, storage used, and a tiered
inactivity indicator, sorted longest-idle first, to support outreach
to site owners about sites that may be safe to archive or retire.
.DESCRIPTION
Uses LastItemUserModifiedDate rather than LastContentModifiedDate or
LastItemModifiedDate, since those two are known to be bumped by system
activity (audit logging, Content Type Hub subscriptions, usage
recalculation, permission changes, feature activation)
Worth knowing: this still cannot distinguish a real human editing
content from an unattended Power Automate flow running under a named
user's delegated connection, both register identically as a user
modification. Treat "recently modified, but very low storage growth
and a single owner who's long gone" as a hint worth double-checking,
not an automatic sign of genuine activity.
.NOTES
Requires: PnP.PowerShell 3.x
Author: Cameron Griffiths
.EXAMPLE
.\Get-SPOSiteActivityAudit.ps1 -AdminUrl "https://contoso-admin.sharepoint.com" -OutputPath ".\SiteActivityAudit.csv"
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $false)]
[string]$AdminUrl = "https://tenantName-admin.sharepoint.com",
[Parameter(Mandatory = $false)]
[string]$ClientId = "",
[Parameter(Mandatory = $false)]
[string]$OutputPath = ".\SiteActivityAudit_$(Get-Date -Format 'yyyyMMdd_HHmmss').csv",
[Parameter(Mandatory = $false)]
[int]$ThrottleDelayMs = 300
)
# --- Known system-managed site templates to skip ---
$systemTemplates = @(
"APPCATALOG#0",
"SRCHCEN#0",
"SPSMSITEHOST#0",
"SPSTOC#0",
"TENANTADMIN#0"
)
$today = Get-Date
# --- 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 site activity" -Status $site.Url -PercentComplete (($counter / $sites.Count) * 100)
if ($systemTemplates -contains $site.Template) {
continue
}
try {
Connect-PnPOnline -Url $site.Url -Interactive -ClientId $ClientId -WarningAction SilentlyContinue
# --- Genuine last user activity ---
$web = Get-PnPWeb -Includes LastItemUserModifiedDate
$lastUserActivity = $web.LastItemUserModifiedDate
$daysSinceActivity = [math]::Round(($today - $lastUserActivity).TotalDays)
# --- Owners group membership, not just the primary Owner field ---
$ownerGroup = Get-PnPGroup -AssociatedOwnerGroup -ErrorAction SilentlyContinue
$owners = @()
if ($ownerGroup) {
$owners = Get-PnPGroupMember -Group $ownerGroup -ErrorAction SilentlyContinue |
Where-Object { $_.PrincipalType -eq "User" -and $_.LoginName -notlike "*spo-grid-all-users*" }
}
$ownerNames = if ($owners.Count -gt 0) { ($owners.Title -join "; ") } else { "NO OWNERS" }
# --- Tiered inactivity indicator ---
$indicator = "Active"
if ($daysSinceActivity -ge 730) {
$indicator = "Inactive 2+ years"
}
elseif ($daysSinceActivity -ge 365) {
$indicator = "Inactive 1-2 years"
}
$report.Add([PSCustomObject]@{
Name = $site.Title
Url = $site.Url
LastUserActivity = $lastUserActivity
DaysSinceActivity = $daysSinceActivity
Owners = $ownerNames
OwnerCount = $owners.Count
StorageUsedMB = $site.StorageUsageCurrent
InactivityIndicator = $indicator
})
}
catch {
$report.Add([PSCustomObject]@{
Name = $site.Title
Url = $site.Url
LastUserActivity = $null
DaysSinceActivity = $null
Owners = ""
OwnerCount = ""
StorageUsedMB = ""
InactivityIndicator = "UNKNOWN - Could not audit: $($_.Exception.Message)"
})
}
Start-Sleep -Milliseconds $ThrottleDelayMs
}
Write-Progress -Activity "Auditing site activity" -Completed
# --- Output, longest-idle first ---
$report | Sort-Object DaysSinceActivity -Descending | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8
Write-Host "`nAudit complete. Report written to $OutputPath" -ForegroundColor Green
$summary = $report | Group-Object InactivityIndicator | Select-Object Name, Count | Sort-Object Name
Write-Host "`nInactivity summary:" -ForegroundColor Yellow
$summary | Format-Table -AutoSize
$noOwners = ($report | Where-Object { $_.Owners -eq "NO OWNERS" }).Count
if ($noOwners -gt 0) {
Write-Host "$noOwners site(s) have no owners at all, worth cross-referencing against the orphaned-site-risk audit before attempting outreach on those." -ForegroundColor Yellow
}
Usage Notes
-AdminUrldefaults to a placeholder — 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
LastItemUserModifiedDateand owner group membership both require a site-level (not admin-level) connection context - Sites the script can't audit — permission issues, deleted sites still listed, timeouts — are captured with an
UNKNOWNindicator rather 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
DaysSinceActivityso the longest-idle sites surface near the top of the CSV, ready for outreach - The run summary flags how many sites have no owners at all — treat those as a signal to check the ownership-risk audit before emailing anyone
Related
- Get-SPOOrphanedSiteRisk — audits sites for zero-owner and single-owner risk, worth cross-referencing before contacting owners flagged here as inactive
- Get-SPOSiteUsageReport — answers the "was it actually visited" half of the picture this script can't; cross-reference by URL before treating a site flagged here as safe to archive
- The SharePoint Sites Nobody Owns — Finding and Fixing Orphaned Site Risk — the governance reasoning behind pairing ownership and activity audits