Auditing External File Sharing in Microsoft 365 with PowerShell

Auditing External File Sharing in Microsoft 365 with PowerShell
Auditing External File Sharing in Microsoft 365 with PowerShell

External sharing is a compliance nightmare. Your 365 tenant has dozens of SharePoint sites, each with document libraries, and files are shared via links or direct invitations. The SharePoint admin center shows you a high-level sharing policy per site, but it won’t tell you which specific files are shared externally or with whom.

I wrote a script that walks every site, every drive, and every file in your tenant using the Microsoft Graph API, then exports a CSV of every externally shared file with who it’s shared with.

Why the Graph API?

The older PnP PowerShell modules can tell you about site-level sharing settings, but file-level permission details require Graph. The Graph endpoints for drive items and permissions give you the actual invitation emails, link scope, and grantee identities.

Prerequisites

You’ll need the Microsoft Graph PowerShell SDK and an account with at least Sites.Read.All and Directories.Read.All scope:

Install-Module Microsoft.Graph
Connect-MgGraph -Scopes "Sites.Read.All", "Directory.Read.All"

For read-only auditing, these scopes are all you need (no global admin required).

Step 1: Enumerate All Sites

$sites = Get-MgSite -All

This returns every SharePoint site and OneDrive for Business drive in the tenant. The -All flag handles pagination automatically.

Step 2: Get Drives Per Site

Each site can have multiple document libraries (drives). You need to check them all:

foreach ($site in $sites) {
    $drives = Get-MgSiteDrive -SiteId $site.Id
    foreach ($drive in $drives) {
        # ... check each drive
    }
}

Step 3: Find Shared Items

This is the key filter — only items that have been shared show up:

$items = Get-MgDriveItem -DriveId $drive.Id -Filter "Shared ne null" -PageSize 5000

The -Filter "Shared ne null" clause is server-side and dramatically reduces the result set. Without it, you’d be pulling every file in every library just to check a flag. The -PageSize 5000 maximizes the batch size to reduce API round trips.

Step 4: Inspect Permissions

For each shared item, the script pulls the detailed permissions and checks two conditions:

  1. Direct invitations — someone outside the organization was explicitly invited ($permission.Invitation.Email)
  2. External links — a sharing link was created with a scope broader than "organization" ($permission.Link.Scope)
$permissions = Get-MgDriveItemPermission -DriveId $drive.Id -DriveItemId $item.Id

foreach ($permission in $permissions) {
    if ($null -ne $permission.Invitation.Email -or
        ($null -ne $permission.Link.Scope -and "organization" -ne $permission.Link.Scope)) {
        # This file is shared externally
    }
}

Step 5: Resolve Grantees

Graph returns grantee information in one of two property paths depending on how the file was shared. The script checks both:

if ($null -ne $permission.GrantedToIdentities) {
    $SharedWithUser = $permission.GrantedToIdentities.User.DisplayName -join ", "
} elseif ($null -ne $permission.GrantedTo) {
    $SharedWithUser = $permission.GrantedTo.User.DisplayName -join ", "
}

This captures users, applications, and devices that have been granted access.

Step 6: Build the Report

Each externally shared file becomes a structured object with the site name, drive name, file name, share scope, share type, web URL, and grantee details:

$SharedFile = [pscustomobject]@{
    SiteName        = $site.DisplayName
    DriveName       = $drive.Name
    FileName        = $item.Name
    FileId          = $item.Id
    DriveId         = $drive.Id
    ShareScope      = $permission.Link.Scope
    ShareType       = $permission.Link.Type
    WebUrl          = $permission.Link.WebUrl
    SharedWithUser  = $SharedWithUser
    SharedWithApp   = $SharedWithApp
    SharedWithDevice = $SharedWithDevice
    InvitationEmail = $permission.Invitation.Email
}

At the end, everything exports to CSV:

$externallySharedFiles | Export-Csv -Path "ExternallySharedFiles.csv" -NoTypeInformation

The Full Script

# Get SharePoint or OneDrive sites (adjust for specific sites or drives if needed)
$sites = Get-MgSite -All

# Initialize an array to store results
$externallySharedFiles = @()

# Loop through each site and its drives (OneDrive and SharePoint sites)
foreach ($site in $sites) {
    Write-Host "Checking site: $($site.DisplayName)"

    # Get all drives (document libraries) within the site
    $drives = Get-MgSiteDrive -SiteId $site.Id

    foreach ($drive in $drives) {
        # Get all items (files and folders) from the drive
        $items = Get-MgDriveItem -DriveId $drive.Id -Filter "Shared ne null" -PageSize 5000
        Write-Host "Checking drive: $($drive.Name) - Count: $($items.Count)"

        foreach ($item in $items) {
            # Check if the item has any sharing permissions or links
            $permissions = Get-MgDriveItemPermission -DriveId $drive.Id -DriveItemId $item.Id

            foreach ($permission in $permissions) {
                if ($null -ne $permission.Invitation.Email -or
                    ($null -ne $permission.Link.Scope -and "organization" -ne $permission.Link.Scope)) {
                    Write-Host "Externally shared files found $($permission.Invitation.Email)"
                    if ($null -ne $permission.GrantedToIdentities) {
                        $SharedWithUser   = $permission.GrantedToIdentities.User.DisplayName -join ", "
                        $SharedWithApp    = $permission.GrantedToIdentities.Application.DisplayName -join ", "
                        $SharedWithDevice = $permission.GrantedToIdentities.Device.DisplayName -join ", "
                    } elseif ($null -ne $permission.GrantedTo) {
                        $SharedWithUser   = $permission.GrantedTo.User.DisplayName -join ", "
                        $SharedWithApp    = $permission.GrantedTo.Application.DisplayName -join ", "
                        $SharedWithDevice = $permission.GrantedTo.Device.DisplayName -join ", "
                    }
                    $SharedFile = [pscustomobject]@{
                        SiteName         = $site.DisplayName
                        DriveName        = $drive.Name
                        FileName         = $item.Name
                        FileId           = $item.Id
                        DriveId          = $drive.Id
                        ShareScope       = $permission.Link.Scope
                        ShareType        = $permission.Link.Type
                        WebUrl           = $permission.Link.WebUrl
                        SharedWithUser   = $SharedWithUser
                        SharedWithApp    = $SharedWithApp
                        SharedWithDevice = $SharedWithDevice
                        InvitationEmail  = $permission.Invitation.Email
                    }
                    $SharedFile
                    # The file has been shared externally
                    $externallySharedFiles += $SharedFile
                }
            }
        }
    }
}

# Output the externally shared files
if ($externallySharedFiles.Count -gt 0) {
    Write-Host "Externally shared files found:"
    $externallySharedFiles | Format-Table -AutoSize
} else {
    Write-Host "No externally shared files found."
}

# Optionally, export results to CSV
$externallySharedFiles | Export-Csv -Path "ExternallySharedFiles.csv" -NoTypeInformation

I’ve also uploaded a copy on GitHub as a gist here.

Performance Considerations

This script is thorough, not fast. For a large tenant with hundreds of sites and millions of files, it will take time. Here are some ways to make it practical:

  • Scope to specific sites — replace Get-MgSite -All with Get-MgSite -Search "Site Name" to target a subset
  • Add rate limiting — Graph has per-user and per-tenant throttling. If you hit 429 errors, add Start-Sleep -Seconds 2 between the inner loop iterations
  • Run it incrementally — add a -ModifiedSince filter to only check files changed in the last 30 days
  • Use app authentication — user-scoped Graph calls are more aggressively throttled than app-registered calls

When to Run This

  • Before changing your tenant’s external sharing policy (to know what will break)
  • As part of a quarterly compliance review
  • After a security incident to find data exfiltration paths
  • During onboarding to understand a new client’s exposure
guest

0 Comments
Inline Feedbacks
View all comments