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.

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 script

Download the exact script used in this guide:

Jump to full inline script on this page

Prerequisites

1) PowerShell version

  • Supported: Windows PowerShell 5.1 and PowerShell 7+ on Windows.
  • Recommended: latest PowerShell 7 for better runtime behavior and module consistency.
  • Download PowerShell: Microsoft official install guide.

2) PnP.PowerShell module

  • Recommended version: latest stable PnP.PowerShell 2.x.
  • Install: Install-Module PnP.PowerShell -Scope CurrentUser
  • Update: Update-Module PnP.PowerShell
  • Check version: Get-Module PnP.PowerShell -ListAvailable | Sort-Object Version -Descending | Select-Object -First 1 Name,Version
  • PnP install docs: pnp.github.io/powershell installation

3) Entra app registration and permissions

You need an Entra application with PnP PowerShell authentication configured.

  • Report-only scans: grant read permissions sufficient for inventory (for example Sites.Read.All).
  • Removal mode: script notes that app-only removal requires broader permission such as Sites.FullControl.All.
  • Grant admin consent before running in production.

If your app is already registered: keep using the same app and skip registration creation. Just verify permissions and proceed.

4) Certificate / auth setup

The script supports:

  • Interactive login (user context)
  • Thumbprint (app-only, certificate in local store)

5) VS Code (optional)

Configuration before first run

Open the script and edit the hard-coded variables at the top:

PowerShell - minimum required config values
$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.

How to run the script

  1. Open PowerShell as your admin/automation identity.
  2. Navigate to the folder containing the script.
  3. Run in report mode first.
PowerShell - report-only run (safe default)
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)

Removal run (only after validation)

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.

Complete script (full copy)

The full script is included below for direct copy/paste use.

Collapse or expand full script
PowerShell - AnonymousLinks_Report_And_Remove.ps1 (full script)
# ============================================================
# 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
        }
    }
}

Understanding output actions

ActionMeaning
ReportOnlyLink was detected and logged. No deletion attempted.
WouldRemovePreview mode. Script logged what would be removed.
RemovedAnonymous link deletion succeeded.
RemoveFailedDeletion attempt failed; check error CSV and transcript.

References from existing OceanCloud guides

Need a safe rollout plan for anonymous link remediation?

OceanCloud can help you run report-only baselines, design approval workflows, and execute controlled remediation across SharePoint and OneDrive.

Book a Governance Review

Frequently Asked Questions

Anonymous 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.

💬

Ready to transform your SharePoint and Microsoft 365 environment?

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 ➜