Report and Remove Anonymous Sharing Links with PnP PowerShell
Use a safety-first script to audit Anyone links across SharePoint Online and OneDrive, then optionally remove them with explicit confirmation.
Use a safety-first script to audit Anyone links across SharePoint Online and OneDrive, then optionally remove them with explicit confirmation.
This guide walks you through running AnonymousLinks_Report_And_Remove.ps1 safely in production. You will start in report-only mode, validate results, and only then enable removal if needed.
Built-in safety: the script defaults to Report mode and requires a second explicit confirmation switch before deleting links.
Download the exact script used in this guide:
Jump to full inline script on this page
Install-Module PnP.PowerShell -Scope CurrentUserUpdate-Module PnP.PowerShellGet-Module PnP.PowerShell -ListAvailable | Sort-Object Version -Descending | Select-Object -First 1 Name,VersionYou need an Entra application with PnP PowerShell authentication configured.
Sites.Read.All).Sites.FullControl.All.If your app is already registered: keep using the same app and skip registration creation. Just verify permissions and proceed.
The script supports:
Interactive login (user context)Thumbprint (app-only, certificate in local store)Open the script and edit the hard-coded variables at the top:
$TenantName = "contoso" $ClientId = "00000000-0000-0000-0000-000000000000" $AuthMode = "Thumbprint" # or "Interactive" $TenantId = "contoso.onmicrosoft.com" $CertificateThumbprint = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" $Mode = "Report" # Report or Remove $ConfirmRemoval = $false # Must be true to allow deletion in Remove mode $WhatIfRemoval = $false # Preview only in Remove mode
Recommended rollout: start with Mode = "Report", review CSV output, then switch to Mode = "Remove" only after approval.
Set-ExecutionPolicy -Scope Process -ExecutionPolicy RemoteSigned .\AnonymousLinks_Report_And_Remove.ps1
After completion, check the generated files:
AnonymousLinks_Report.csv (main findings and actions)AnonymousLinks_Report_Errors.csv (failures by stage)AnonymousLinks_Transcript_yyyyMMdd_HHmmss.txt (execution log)To remove anonymous links, set all of the following in script config:
$Mode = "Remove"$ConfirmRemoval = $true$WhatIfRemoval = $false (or $true to preview only)Scope reminder: according to script notes and PnP behavior, removal cmdlets target file/folder sharing links. List-item link removal is not covered by these commands.
The full script is included below for direct copy/paste use.
# ============================================================
# Anonymous / "Anyone" Sharing Link Report + Remediation
# SharePoint Online + OneDrive
# ============================================================
# Purpose:
# Reports existing "Anyone with the link" / anonymous sharing links,
# and (optionally) REMOVES them.
#
# Modes ($Mode):
# "Report" -> REPORT-ONLY. Never deletes. (DEFAULT - safe)
# "Remove" -> Reports AND removes anonymous links.
# Removal additionally requires $ConfirmRemoval = $true.
#
# Safety model (defence in depth):
# 1. $Mode defaults to "Report". Destructive path is never the default.
# 2. Even in "Remove" mode, nothing is deleted unless $ConfirmRemoval = $true.
# 3. $WhatIfRemoval = $true logs intended deletions WITHOUT calling Remove-*.
# 4. Every action (report / remove / failure) is written to the CSV.
#
# IMPORTANT LIMITATION (per PnP docs):
# Remove-PnPFileSharingLink / Remove-PnPFolderSharingLink remove links at the
# FILE and FOLDER level only. They do NOT remove list-ITEM level links.
#
# Verified against PnP PowerShell official docs:
# Get-PnPFileSharingLink -Identity <server-relative url | UniqueId | item | file>
# Get-PnPFolderSharingLink -Folder <server-relative url | folder object>
# Remove-PnPFileSharingLink -FileUrl <server-relative url> -Identity <Id> -Force
# Remove-PnPFolderSharingLink -Folder <server-relative url> -Identity <Id> -Force
# Sharing link object model:
# .Id, .Roles, .ExpirationDateTime, .HasPassword,
# .GrantedToIdentitiesV2, and nested .Link.{WebUrl,Type,Scope,PreventsDownload}
# .Link.Scope values: Anonymous | Organization | Users
# ============================================================
# ============================================================
# Hard-coded configuration
# ============================================================
# Tenant details
$TenantName = "contoso"
$ClientId = "00000000-0000-0000-0000-000000000000"
# Authentication mode
# Valid values:
# Interactive
# Thumbprint
$AuthMode = "Thumbprint"
# Required only when $AuthMode = "Thumbprint"
# For Interactive auth, these values are ignored.
$TenantId = "contoso.onmicrosoft.com"
$CertificateThumbprint = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
# ------------------------------------------------------------
# MODE + REMOVAL SAFETY SWITCHES
# ------------------------------------------------------------
# $Mode:
# "Report" -> only report anonymous links (DEFAULT, safe)
# "Remove" -> report AND remove anonymous links (requires confirmation below)
$Mode = "Report"
# Master safety gate. Removal will NOT occur unless this is exactly $true,
# even when $Mode = "Remove". Forces a deliberate two-step opt-in.
$ConfirmRemoval = $false
# Dry-run removal preview. When $true (and $Mode = "Remove"), the script logs
# what WOULD be removed but does not call Remove-* cmdlets.
$WhatIfRemoval = $false
# Include OneDrive sites in the scan
# NOTE: For app-only (Thumbprint) auth, the Entra app needs Sites.FullControl.All
# to remove sharing links (Sites.Read.All is enough only for reporting).
$IncludeOneDriveSites = $true
# Report paths
$ReportPath = ".\AnonymousLinks_Report.csv"
$ErrorReportPath = ".\AnonymousLinks_Report_Errors.csv"
# Transcript path
$TranscriptPath = ".\AnonymousLinks_Transcript_$((Get-Date).ToString('yyyyMMdd_HHmmss')).txt"
# Scan settings
$PageSize = 500
$OverwriteExistingReports = $true
# Throttling / retry settings
$MaxRetries = 5 # Max retry attempts per throttled call (outer net)
$BaseRetryDelay = 5 # Base seconds for exponential backoff
$MaxBackoffSeconds = 300 # Hard ceiling on any single wait (5 min)
$DelayBetweenSites = 1 # Seconds to pause between site connections
# Proactive pacing (throttle AVOIDANCE, not just recovery).
# A small delay before each per-item sharing-link call keeps request rate
# under the per-minute limit. Increase if you still see throttling; set to 0
# to disable. This is the most effective lever for large tenants/OneDrive.
$DelayBetweenItemCalls = 0.1 # Seconds (fractional allowed)
# Libraries to exclude from scanning (by Title).
# NOTE: Titles are localized; on non-English tenants these may differ.
$ExcludedLibraries = @(
"Form Templates",
"Preservation Hold Library",
"Style Library",
"Site Assets",
"Site Pages"
)
# Site templates to skip entirely (redirect sites error on connect)
$ExcludedSiteTemplates = @(
"RedirectSite#0"
)
# ============================================================
# Pre-checks
# ============================================================
$ErrorActionPreference = "Stop"
$RunTimestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$TranscriptStarted = $false
if ($AuthMode -notin @("Interactive", "Thumbprint")) {
throw "Invalid AuthMode value. Use either 'Interactive' or 'Thumbprint'. Current value: $AuthMode"
}
if ($Mode -notin @("Report", "Remove")) {
throw "Invalid Mode value. Use either 'Report' or 'Remove'. Current value: $Mode"
}
if ([string]::IsNullOrWhiteSpace($ClientId)) {
throw "ClientId is required for both Interactive and Thumbprint authentication."
}
if ($AuthMode -eq "Thumbprint") {
if ([string]::IsNullOrWhiteSpace($TenantId)) {
throw "TenantId is required when AuthMode is Thumbprint."
}
if ([string]::IsNullOrWhiteSpace($CertificateThumbprint)) {
throw "CertificateThumbprint is required when AuthMode is Thumbprint."
}
}
# Resolve the EFFECTIVE removal behaviour up front so it is logged clearly.
# $RemovalEnabled = links will actually be deleted
# $RemovalPreview = "Remove" requested but running as WhatIf preview
$RemovalEnabled = $false
$RemovalPreview = $false
if ($Mode -eq "Remove") {
if ($WhatIfRemoval) {
$RemovalPreview = $true
}
elseif ($ConfirmRemoval -eq $true) {
$RemovalEnabled = $true
}
else {
Write-Warning "Mode is 'Remove' but ConfirmRemoval is not set to `$true. Running as REPORT-ONLY. Set `$ConfirmRemoval = `$true to enable deletion, or `$WhatIfRemoval = `$true to preview."
}
}
try {
Start-Transcript -Path $TranscriptPath -Force | Out-Null
$TranscriptStarted = $true
Write-Host "Transcript started: $TranscriptPath" -ForegroundColor Green
}
catch {
Write-Warning "Unable to start transcript. Error: $($_.Exception.Message)"
}
try {
Import-Module PnP.PowerShell -ErrorAction Stop
}
catch {
throw "PnP.PowerShell module is not installed or could not be loaded. Install it using: Install-Module PnP.PowerShell -Scope CurrentUser"
}
if ($OverwriteExistingReports) {
if (Test-Path $ReportPath) { Remove-Item $ReportPath -Force }
if (Test-Path $ErrorReportPath) { Remove-Item $ErrorReportPath -Force }
}
# ============================================================
# Helper functions
# ============================================================
function Connect-ToPnPSite {
param(
[Parameter(Mandatory = $true)]
[string]$Url
)
if ($AuthMode -eq "Interactive") {
Connect-PnPOnline -Url $Url -Interactive -ClientId $ClientId
}
elseif ($AuthMode -eq "Thumbprint") {
Connect-PnPOnline `
-Url $Url `
-ClientId $ClientId `
-Tenant $TenantId `
-Thumbprint $CertificateThumbprint
}
}
# Detect throttling-related errors (HTTP 429 / 503 / explicit throttle text).
# Status-code inspection is authoritative; text match is word-bounded to avoid
# false positives from GUIDs/paths that merely contain "429".
function Test-IsThrottlingError {
param([System.Management.Automation.ErrorRecord]$ErrorRecord)
$msg = $ErrorRecord.Exception.Message
if ($msg -match "(?i)\bthrottl|too many requests\b|\b429\b|\b503\b|temporarily unavailable|service unavailable") {
return $true
}
# Inspect inner web response status code where available.
$ex = $ErrorRecord.Exception
while ($ex) {
$resp = $ex.PSObject.Properties['Response']
if ($resp -and $resp.Value) {
$code = $resp.Value.PSObject.Properties['StatusCode']
if ($code -and ($code.Value -in @(429, 503))) {
return $true
}
}
$ex = $ex.InnerException
}
return $false
}
# Try to read a Retry-After hint (seconds) from a throttling error, if present.
# Header collections differ by exception type:
# - CSOM/WebException -> WebHeaderCollection (string indexer returns a string)
# - Graph/HttpResponse -> HttpResponseHeaders (string indexer returns IEnumerable<string>)
# This handles both, plus a regex fallback on the raw message.
function Get-RetryAfterSeconds {
param([System.Management.Automation.ErrorRecord]$ErrorRecord)
$ex = $ErrorRecord.Exception
while ($ex) {
$resp = $ex.PSObject.Properties['Response']
if ($resp -and $resp.Value) {
try {
$headers = $resp.Value.Headers
if ($headers) {
$raw = $null
# HttpResponseHeaders: use TryGetValues (returns IEnumerable<string>).
if ($headers -is [System.Net.Http.Headers.HttpHeaders]) {
$values = $null
if ($headers.TryGetValues("Retry-After", [ref]$values) -and $values) {
$raw = @($values)[0]
}
}
else {
# WebHeaderCollection or dictionary-like: string indexer.
$candidate = $headers["Retry-After"]
if ($candidate -is [System.Array]) {
$raw = @($candidate)[0]
}
else {
$raw = $candidate
}
}
if ($raw) {
$seconds = 0
if ([int]::TryParse([string]$raw, [ref]$seconds) -and $seconds -gt 0) {
return $seconds
}
}
}
}
catch {
# Header not accessible; fall through to message/backoff.
}
}
$ex = $ex.InnerException
}
# Fallback: parse "Retry-After: NN" from the error message text if present.
$m = [regex]::Match($ErrorRecord.Exception.Message, "(?i)retry[- ]after[:=]?\s*(\d+)")
if ($m.Success) {
return [int]$m.Groups[1].Value
}
return 0
}
# Execute a scriptblock with throttling-aware retry + exponential backoff.
#
# NOTE: PnP PowerShell (PnP.Framework / PnP.Core) ALREADY retries 429/503
# internally with its own incremental backoff and honors Retry-After. This
# wrapper is an OUTER safety net for the rare case where an exception still
# escapes after PnP exhausts its internal attempts. To avoid hammering an
# already-throttled tenant (which prolongs throttling), waits are conservative
# and capped, and we always prefer a server-provided Retry-After value.
function Invoke-WithRetry {
param(
[Parameter(Mandatory = $true)]
[scriptblock]$ScriptBlock,
[string]$OperationName = "operation"
)
$attempt = 0
while ($true) {
try {
return & $ScriptBlock
}
catch {
$attempt++
if ((Test-IsThrottlingError -ErrorRecord $_) -and ($attempt -le $MaxRetries)) {
$retryAfter = Get-RetryAfterSeconds -ErrorRecord $_
if ($retryAfter -gt 0) {
# Honor server instruction exactly (capped to a sane ceiling).
$wait = [math]::Min($retryAfter, $MaxBackoffSeconds)
}
else {
# Exponential backoff, capped.
$computed = $BaseRetryDelay * [math]::Pow(2, ($attempt - 1))
$wait = [math]::Min($computed, $MaxBackoffSeconds)
}
# Start-Sleep -Seconds wants an int; round up so we never under-wait.
$waitSeconds = [int][math]::Ceiling($wait)
Write-Host " Throttled on $OperationName (attempt $attempt/$MaxRetries). Waiting $waitSeconds s..." -ForegroundColor Yellow
Start-Sleep -Seconds $waitSeconds
continue
}
throw
}
}
}
function Convert-ToText {
param([object]$Value)
if ($null -eq $Value) { return "" }
if ($Value -is [System.Array]) { return ($Value -join ";") }
return [string]$Value
}
function Write-CsvRow {
param(
[Parameter(Mandatory = $true)] [object]$Row,
[Parameter(Mandatory = $true)] [string]$Path
)
if (Test-Path $Path) {
$Row | Export-Csv -Path $Path -NoTypeInformation -Append -Encoding UTF8
}
else {
$Row | Export-Csv -Path $Path -NoTypeInformation -Encoding UTF8
}
}
function Write-ScanError {
param(
[string]$SiteUrl,
[string]$Library,
[string]$ItemUrl,
[string]$Stage,
[string]$ErrorMessage
)
$errorRow = [PSCustomObject]@{
RunTimestamp = $RunTimestamp
SiteUrl = $SiteUrl
Library = $Library
ItemUrl = $ItemUrl
Stage = $Stage
Error = $ErrorMessage
}
Write-CsvRow -Row $errorRow -Path $ErrorReportPath
}
function Get-PercentComplete {
param([int]$Current, [int]$Total)
if ($Total -le 0) { return 0 }
return [math]::Min(100, [math]::Round(($Current / $Total) * 100, 0))
}
# Resolve the editor's email/display name from the Editor FieldUserValue.
function Get-EditorText {
param([object]$EditorValue)
if ($null -eq $EditorValue) { return "" }
$email = $null
if ($EditorValue.PSObject.Properties.Name -contains "Email") {
$email = $EditorValue.Email
}
if (-not [string]::IsNullOrWhiteSpace($email)) {
return Convert-ToText $email
}
if ($EditorValue.PSObject.Properties.Name -contains "LookupValue") {
return Convert-ToText $EditorValue.LookupValue
}
return Convert-ToText $EditorValue
}
# Read a property from the nested .Link object using the documented model.
function Get-LinkProperty {
param(
[object]$SharingLink,
[string]$PropertyName
)
if ($null -eq $SharingLink) { return "" }
$linkObj = $null
if ($SharingLink.PSObject.Properties.Name -contains "Link") {
$linkObj = $SharingLink.Link
}
if ($linkObj -and ($linkObj.PSObject.Properties.Name -contains $PropertyName)) {
return Convert-ToText $linkObj.$PropertyName
}
return ""
}
# Anonymous detection keyed strictly on the documented .Link.Scope value.
function Test-IsAnonymousLink {
param([object]$SharingLink)
$scope = Get-LinkProperty -SharingLink $SharingLink -PropertyName "Scope"
return ($scope -match "(?i)^anonymous$")
}
# Safely read a top-level scalar property from a sharing link.
function Get-TopLevelProperty {
param([object]$SharingLink, [string]$PropertyName)
if ($null -eq $SharingLink) { return "" }
if ($SharingLink.PSObject.Properties.Name -contains $PropertyName) {
return Convert-ToText $SharingLink.$PropertyName
}
return ""
}
# ------------------------------------------------------------
# Remove an anonymous sharing link.
# Returns one of: "Removed", "WouldRemove", "RemoveFailed"
# ------------------------------------------------------------
function Remove-AnonymousSharingLink {
param(
[Parameter(Mandatory = $true)] [string]$ObjectType, # "File" or "Folder"
[Parameter(Mandatory = $true)] [string]$FileRef,
[Parameter(Mandatory = $true)] [string]$LinkId,
[Parameter(Mandatory = $true)] [ref]$ErrorMessageRef
)
if ([string]::IsNullOrWhiteSpace($LinkId)) {
$ErrorMessageRef.Value = "Sharing link Id was empty; cannot target removal."
return "RemoveFailed"
}
# Preview mode: log intent without calling Remove-*.
if ($RemovalPreview) {
return "WouldRemove"
}
try {
Invoke-WithRetry -OperationName "Remove-Sharing-Link" -ScriptBlock {
if ($ObjectType -eq "Folder") {
Remove-PnPFolderSharingLink -Folder $FileRef -Identity $LinkId -Force
}
else {
Remove-PnPFileSharingLink -FileUrl $FileRef -Identity $LinkId -Force
}
}
return "Removed"
}
catch {
$ErrorMessageRef.Value = $_.Exception.Message
return "RemoveFailed"
}
}
# ============================================================
# Start scan
# ============================================================
try {
$adminUrl = "https://$TenantName-admin.sharepoint.com"
if ($RemovalEnabled) {
$effectiveAction = "REMOVE (links will be deleted)"
}
elseif ($RemovalPreview) {
$effectiveAction = "REMOVE PREVIEW (WhatIf - nothing deleted)"
}
else {
$effectiveAction = "REPORT ONLY"
}
Write-Host "============================================================" -ForegroundColor Cyan
Write-Host "Anonymous / Anyone Sharing Link - Report & Remediation" -ForegroundColor Cyan
Write-Host "Tenant Admin URL: $adminUrl" -ForegroundColor Cyan
Write-Host "Authentication Mode: $AuthMode" -ForegroundColor Cyan
Write-Host "Requested Mode: $Mode" -ForegroundColor Cyan
Write-Host "Effective Action: $effectiveAction" -ForegroundColor $(if ($RemovalEnabled) { "Red" } else { "Cyan" })
Write-Host "Include OneDrive Sites: $IncludeOneDriveSites" -ForegroundColor Cyan
Write-Host "Report Path: $ReportPath" -ForegroundColor Cyan
Write-Host "Error Report Path: $ErrorReportPath" -ForegroundColor Cyan
Write-Host "Transcript Path: $TranscriptPath" -ForegroundColor Cyan
Write-Host "============================================================" -ForegroundColor Cyan
if ($RemovalEnabled) {
Write-Host "`n*** DELETION IS ENABLED. Anonymous links found will be PERMANENTLY removed. ***" -ForegroundColor Red
}
Write-Progress -Id 1 -Activity "Anonymous sharing link scan" -Status "Connecting to SharePoint Admin Center..." -PercentComplete 0
Write-Host "`nConnecting to SharePoint Admin Center..." -ForegroundColor Cyan
Invoke-WithRetry -OperationName "admin connect" -ScriptBlock {
Connect-ToPnPSite -Url $adminUrl
}
Write-Progress -Id 1 -Activity "Anonymous sharing link scan" -Status "Retrieving tenant sites..." -PercentComplete 2
Write-Host "Retrieving tenant sites..." -ForegroundColor Cyan
$sites = Invoke-WithRetry -OperationName "Get-PnPTenantSite" -ScriptBlock {
if ($IncludeOneDriveSites) {
Get-PnPTenantSite -Detailed -IncludeOneDriveSites
}
else {
Get-PnPTenantSite -Detailed
}
}
$sites = @($sites)
$totalSites = $sites.Count
$currentSiteNumber = 0
$totalAnonymousLinks = 0
$totalItemsScanned = 0
$totalLibrariesScanned = 0
$totalErrors = 0
$totalRemoved = 0
$totalWouldRemove = 0
$totalRemoveFailed = 0
Write-Host "Total sites found: $totalSites" -ForegroundColor Green
foreach ($site in $sites) {
$currentSiteNumber++
$sitePercent = Get-PercentComplete -Current $currentSiteNumber -Total $totalSites
if (-not $site.Url) { continue }
$siteUrl = $site.Url
$siteTitle = Convert-ToText $site.Title
$siteTemplate = Convert-ToText $site.Template
$siteLockState = Convert-ToText $site.LockState
Write-Progress -Id 1 -Activity "Scanning SharePoint and OneDrive sites" -Status "Site $currentSiteNumber of $totalSites : $siteUrl" -PercentComplete $sitePercent
Write-Host "`n[$currentSiteNumber/$totalSites] Processing site: $siteUrl" -ForegroundColor Green
if ($siteTemplate -in $ExcludedSiteTemplates) {
Write-Host " Skipping excluded template ($siteTemplate): $siteUrl" -ForegroundColor Yellow
continue
}
if ($siteLockState -eq "NoAccess") {
Write-Host " Skipping locked site: $siteUrl" -ForegroundColor Yellow
Write-ScanError -SiteUrl $siteUrl -Library "" -ItemUrl "" -Stage "SiteSkipped" -ErrorMessage "Site lock state is NoAccess"
$totalErrors++
continue
}
if ($DelayBetweenSites -gt 0) { Start-Sleep -Seconds $DelayBetweenSites }
try {
Invoke-WithRetry -OperationName "site connect" -ScriptBlock {
Connect-ToPnPSite -Url $siteUrl
}
}
catch {
Write-Host " Failed to connect to site: $($_.Exception.Message)" -ForegroundColor Red
Write-ScanError -SiteUrl $siteUrl -Library "" -ItemUrl "" -Stage "SiteConnectionFailed" -ErrorMessage $_.Exception.Message
$totalErrors++
continue
}
try {
$libraries = Invoke-WithRetry -OperationName "Get-PnPList" -ScriptBlock {
Get-PnPList -Includes RootFolder, HasUniqueRoleAssignments |
Where-Object {
$_.BaseTemplate -eq 101 `
-and $_.Hidden -eq $false `
-and $_.ItemCount -gt 0 `
-and $_.Title -notin $ExcludedLibraries
}
}
$libraries = @($libraries)
}
catch {
Write-Host " Failed to retrieve libraries: $($_.Exception.Message)" -ForegroundColor Red
Write-ScanError -SiteUrl $siteUrl -Library "" -ItemUrl "" -Stage "LibraryEnumerationFailed" -ErrorMessage $_.Exception.Message
$totalErrors++
continue
}
$totalLibrariesInSite = $libraries.Count
$currentLibraryNumber = 0
foreach ($library in $libraries) {
$currentLibraryNumber++
$libraryPercent = Get-PercentComplete -Current $currentLibraryNumber -Total $totalLibrariesInSite
$libraryTitle = Convert-ToText $library.Title
$totalLibrariesScanned++
Write-Progress -Id 2 -ParentId 1 -Activity "Scanning libraries in current site" -Status "Library $currentLibraryNumber of $totalLibrariesInSite : $libraryTitle" -PercentComplete $libraryPercent
Write-Host " Library: $libraryTitle | Items: $($library.ItemCount)" -ForegroundColor DarkCyan
try {
$items = Invoke-WithRetry -OperationName "Get-PnPListItem" -ScriptBlock {
Get-PnPListItem `
-List $library `
-PageSize $PageSize `
-Fields "FileRef", "FileLeafRef", "FSObjType", "Modified", "Editor", "UniqueId"
}
$items = @($items)
}
catch {
Write-Host " Failed to retrieve items: $($_.Exception.Message)" -ForegroundColor Red
Write-ScanError -SiteUrl $siteUrl -Library $libraryTitle -ItemUrl "" -Stage "ItemEnumerationFailed" -ErrorMessage $_.Exception.Message
$totalErrors++
continue
}
$totalItemsInLibrary = $items.Count
$currentItemNumber = 0
foreach ($item in $items) {
$currentItemNumber++
$totalItemsScanned++
if (($currentItemNumber % 25 -eq 0) -or ($currentItemNumber -eq 1) -or ($currentItemNumber -eq $totalItemsInLibrary)) {
$itemPercent = Get-PercentComplete -Current $currentItemNumber -Total $totalItemsInLibrary
Write-Progress -Id 3 -ParentId 2 -Activity "Scanning items in current library" -Status "Item $currentItemNumber of $totalItemsInLibrary" -PercentComplete $itemPercent
}
$fileRef = Convert-ToText $item.FieldValues["FileRef"]
$itemName = Convert-ToText $item.FieldValues["FileLeafRef"]
$uniqueId = Convert-ToText $item.FieldValues["UniqueId"]
$modified = Convert-ToText $item.FieldValues["Modified"]
$modifiedBy = Get-EditorText -EditorValue $item.FieldValues["Editor"]
if (-not $fileRef) { continue }
try {
$fsObjType = [int]$item.FieldValues["FSObjType"]
}
catch {
$fsObjType = 0
}
$objectType = if ($fsObjType -eq 1) { "Folder" } else { "File" }
# Performance: only items with unique role assignments can carry
# their own sharing links. Skip everything that inherits.
try {
$hasUnique = Invoke-WithRetry -OperationName "HasUniqueRoleAssignments" -ScriptBlock {
Get-PnPProperty -ClientObject $item -Property "HasUniqueRoleAssignments"
}
}
catch {
Write-ScanError -SiteUrl $siteUrl -Library $libraryTitle -ItemUrl $fileRef -Stage "UniquePermissionCheckFailed" -ErrorMessage $_.Exception.Message
$totalErrors++
continue
}
if (-not $hasUnique) { continue }
# Proactive pacing: throttle avoidance for the (rarer) items that
# actually require a sharing-link Graph call. Skipped items above
# incur no delay, so this only paces the calls that matter.
if ($DelayBetweenItemCalls -gt 0) {
Start-Sleep -Milliseconds ([int]($DelayBetweenItemCalls * 1000))
}
try {
$sharingLinks = Invoke-WithRetry -OperationName "Get-Sharing-Link" -ScriptBlock {
if ($objectType -eq "Folder") {
Get-PnPFolderSharingLink -Folder $fileRef
}
else {
Get-PnPFileSharingLink -Identity $fileRef
}
}
$sharingLinks = @($sharingLinks)
}
catch {
Write-ScanError -SiteUrl $siteUrl -Library $libraryTitle -ItemUrl $fileRef -Stage "SharingLinkReadFailed" -ErrorMessage $_.Exception.Message
$totalErrors++
continue
}
foreach ($sharingLink in $sharingLinks) {
if (-not (Test-IsAnonymousLink -SharingLink $sharingLink)) { continue }
$totalAnonymousLinks++
$linkId = Get-TopLevelProperty -SharingLink $sharingLink -PropertyName "Id"
$linkRoles = Get-TopLevelProperty -SharingLink $sharingLink -PropertyName "Roles"
$linkExpiration = Get-TopLevelProperty -SharingLink $sharingLink -PropertyName "ExpirationDateTime"
$hasPassword = Get-TopLevelProperty -SharingLink $sharingLink -PropertyName "HasPassword"
$grantedTo = ""
if ($sharingLink.PSObject.Properties.Name -contains "GrantedToIdentitiesV2") {
try {
$grantedTo = Convert-ToText ($sharingLink.GrantedToIdentitiesV2.User.Email)
}
catch { $grantedTo = "" }
}
# ---- Decide action ----
$action = "ReportOnly"
$actionError = ""
if ($RemovalEnabled -or $RemovalPreview) {
$errRef = [ref]""
$result = Remove-AnonymousSharingLink `
-ObjectType $objectType `
-FileRef $fileRef `
-LinkId $linkId `
-ErrorMessageRef $errRef
switch ($result) {
"Removed" {
$action = "Removed"
$totalRemoved++
Write-Host " Removed anonymous link on: $fileRef" -ForegroundColor Magenta
}
"WouldRemove" {
$action = "WouldRemove"
$totalWouldRemove++
}
"RemoveFailed" {
$action = "RemoveFailed"
$actionError = $errRef.Value
$totalRemoveFailed++
Write-Host " FAILED to remove link on: $fileRef ($($errRef.Value))" -ForegroundColor Red
Write-ScanError -SiteUrl $siteUrl -Library $libraryTitle -ItemUrl $fileRef -Stage "SharingLinkRemoveFailed" -ErrorMessage $errRef.Value
$totalErrors++
}
}
}
$reportRow = [PSCustomObject]@{
RunTimestamp = $RunTimestamp
SiteUrl = $siteUrl
SiteTitle = $siteTitle
SiteTemplate = $siteTemplate
SiteLockState = $siteLockState
Library = $libraryTitle
ObjectType = $objectType
ItemName = $itemName
ItemUrl = $fileRef
UniqueId = $uniqueId
Modified = $modified
ModifiedBy = $modifiedBy
LinkId = $linkId
LinkScope = Get-LinkProperty -SharingLink $sharingLink -PropertyName "Scope"
LinkType = Get-LinkProperty -SharingLink $sharingLink -PropertyName "Type"
LinkRoles = $linkRoles
BlocksDownload = Get-LinkProperty -SharingLink $sharingLink -PropertyName "PreventsDownload"
RequiresPassword = $hasPassword
GrantedTo = $grantedTo
ExpirationDateTime = $linkExpiration
SharingLinkUrl = Get-LinkProperty -SharingLink $sharingLink -PropertyName "WebUrl"
Action = $action
Error = $actionError
}
Write-CsvRow -Row $reportRow -Path $ReportPath
}
}
Write-Progress -Id 3 -ParentId 2 -Activity "Scanning items in current library" -Completed
}
Write-Progress -Id 2 -ParentId 1 -Activity "Scanning libraries in current site" -Completed
}
if (-not (Test-Path $ReportPath)) {
$emptyRow = [PSCustomObject]@{
RunTimestamp = $RunTimestamp
SiteUrl = ""
SiteTitle = ""
SiteTemplate = ""
SiteLockState = ""
Library = ""
ObjectType = ""
ItemName = ""
ItemUrl = ""
UniqueId = ""
Modified = ""
ModifiedBy = ""
LinkId = ""
LinkScope = ""
LinkType = ""
LinkRoles = ""
BlocksDownload = ""
RequiresPassword = ""
GrantedTo = ""
ExpirationDateTime = ""
SharingLinkUrl = ""
Action = "NoAnonymousLinksFound"
Error = ""
}
Write-CsvRow -Row $emptyRow -Path $ReportPath
}
Write-Progress -Id 1 -Activity "Anonymous sharing link scan" -Status "Completed" -PercentComplete 100
Write-Host "`n============================================================" -ForegroundColor Cyan
Write-Host "Scan completed." -ForegroundColor Green
Write-Host "Effective action: $effectiveAction" -ForegroundColor Cyan
Write-Host "Authentication mode: $AuthMode" -ForegroundColor Cyan
Write-Host "Sites processed: $totalSites" -ForegroundColor Cyan
Write-Host "Libraries scanned: $totalLibrariesScanned" -ForegroundColor Cyan
Write-Host "Items scanned: $totalItemsScanned" -ForegroundColor Cyan
Write-Host "Anonymous / Anyone links found: $totalAnonymousLinks" -ForegroundColor Yellow
if ($RemovalEnabled) {
Write-Host "Links removed: $totalRemoved" -ForegroundColor Magenta
Write-Host "Remove failures: $totalRemoveFailed" -ForegroundColor Red
}
elseif ($RemovalPreview) {
Write-Host "Links that WOULD be removed: $totalWouldRemove" -ForegroundColor Magenta
}
Write-Host "Errors logged: $totalErrors" -ForegroundColor Yellow
Write-Host "Main report: $ReportPath" -ForegroundColor Green
Write-Host "Error report: $ErrorReportPath" -ForegroundColor Green
Write-Host "Transcript: $TranscriptPath" -ForegroundColor Green
Write-Host "============================================================" -ForegroundColor Cyan
}
finally {
Write-Progress -Id 3 -Activity "Scanning items in current library" -Completed
Write-Progress -Id 2 -Activity "Scanning libraries in current site" -Completed
Write-Progress -Id 1 -Activity "Anonymous sharing link scan" -Completed
try {
Disconnect-PnPOnline -ErrorAction SilentlyContinue
}
catch {
# Ignore disconnect failure
}
if ($TranscriptStarted) {
try {
Stop-Transcript | Out-Null
}
catch {
# Ignore transcript stop failure
}
}
}
| Action | Meaning |
|---|---|
| ReportOnly | Link was detected and logged. No deletion attempted. |
| WouldRemove | Preview mode. Script logged what would be removed. |
| Removed | Anonymous link deletion succeeded. |
| RemoveFailed | Deletion attempt failed; check error CSV and transcript. |
OceanCloud can help you run report-only baselines, design approval workflows, and execute controlled remediation across SharePoint and OneDrive.
Book a Governance ReviewAnonymous links (also called Anyone links) give access to a SharePoint file or folder to anyone with the link — no sign-in required.
Once shared, the link can be forwarded to anyone outside your organisation without any audit trail. If the link is posted publicly or forwarded to the wrong person, the content is exposed with no access controls.
Use the SharePoint admin center's Active sites > Sharing report for a high-level view, or use PnP PowerShell with Get-PnPListItem and Get-PnPFileSharingLink to enumerate sharing links across all site collections.
The OceanCloud anonymous links script automates tenant-wide enumeration and exports results to CSV.
Yes.
Removing an anonymous sharing link only revokes the Anyone link access — it does not remove permissions for users who were added directly (Specific People links) or Microsoft 365 group members. Use Revoke-PnPFileSharingLink with the specific link ID to target only anonymous links.
In the SharePoint admin center, go to Policies > Sharing and set the external sharing level to 'New and existing guests' or lower.
Set the default sharing link type to 'Specific people' rather than 'Anyone'. You can also set expiry dates on Anyone links at the tenant level under the 'Advanced settings for external sharing' section.
Yes — anyone who has an existing Anyone link will lose access when you disable or remove it.
Before bulk removal, identify links that are actively used (check the SharePoint admin sharing activity report) and notify document owners. Give users 2–4 weeks notice and a process to reshare via Specific People links before revoking existing anonymous links.
OceanCloud specialises in SharePoint consulting, M365 migration, Power Platform solutions, and enterprise governance. Let's discuss how we can help.
Book a Free 60-Minute Consultation ➜