PnP PowerShell

Find Broken Permission Inheritance in SharePoint with PowerShell

In this post, I am going to show you how to find everything in a SharePoint site that does not inherit its permissions, and who can actually get at it. There is a full script at the end if you just want to run something.

Broken inheritance is where oversharing hides. A site can look tidy at the top and still have one folder shared with everyone, because somebody clicked Share eighteen months ago. Nothing in the interface shows you all of them at once.

Run against a real site on PnP.PowerShell 2.12.0. Output below is what came back.

Permissions break at four levels

Site, list, folder and item. You have to check all four, and the checks are different at each.

The site

$web = Get-PnPWeb
Get-PnPProperty -ClientObject $web -Property HasUniqueRoleAssignments
$web.HasUniqueRoleAssignments
True

Do not panic at that. The top web of a site collection has nothing above it to inherit from, so it always reports True. It only means something on a subsite.

The important thing to note is Get-PnPProperty. Most permission properties are not loaded when you fetch an object – you have to ask for them separately. Skip that line and HasUniqueRoleAssignments comes back empty rather than throwing, which is worse, because your report quietly finds nothing.

Lists and libraries

Get-PnPList | Where-Object { -not $_.Hidden } | ForEach-Object {
    [pscustomobject]@{
        List   = $_.Title
        Unique = Get-PnPProperty -ClientObject $_ -Property HasUniqueRoleAssignments
    }
}
List              Unique
----              ------
Departments        False
Documents          False
Events             False
Project Documents  False
Projects           False
Site Pages         False
Style Library      False

Items, and why this is the slow part

Get-PnPListItem -List 'Projects' -PageSize 500 | ForEach-Object {
    $unique = Get-PnPProperty -ClientObject $_ -Property HasUniqueRoleAssignments
    if ($unique) { [pscustomobject]@{ Id = $_.Id; Title = $_['Title'] } }
}
Id Title
-- -----
 3 Project 03

That took 6.2 seconds for 25 items.

Each item is a separate round trip to the server. There is no bulk way to ask. So a 10,000 item library is roughly forty minutes, and a large tenant is an overnight job. That is not a fault in the script, it is how the API works, and it is why you should scope these reports to a site rather than pointing them at everything and hoping.

Folders, and the one that breaks your script

The obvious way to check folders looks like this, and it does not work:

Get-PnPFolderItem -FolderSiteRelativeUrl 'Project Documents' -ItemType Folder | ForEach-Object {
    $folderItem = Get-PnPProperty -ClientObject $_ -Property ListItemAllFields
    Get-PnPProperty -ClientObject $folderItem -Property HasUniqueRoleAssignments
}
Object reference not set to an instance of an object on server.
The object is associated with property ListItemAllFields.

The culprit is Forms. Every document library has one, SharePoint made it, it holds the library’s form pages, and it is not backed by a list item at all. So asking it about ListItemAllFields returns nothing and the next line falls over.

Note the error does not tell you which folder caused it. You get an object reference exception on a folder you did not create and never see in the browser.

Ask the list for its folders instead:

Get-PnPListItem -List 'Project Documents' -PageSize 500 |
    Where-Object { $_.FileSystemObjectType -eq 'Folder' } |
    ForEach-Object {
        [pscustomobject]@{
            Folder = $_['FileLeafRef']
            Unique = Get-PnPProperty -ClientObject $_ -Property HasUniqueRoleAssignments
        }
    }
Folder    Unique
------    ------
Contracts  False
Reports    False
Archive     True
2024       False
2025       False

Forms never appears, because it is not a list item. Problem gone rather than worked around.

Who actually has access

Knowing something is broken is half of it. You want to know who that let in:

Get-PnPListItemPermission -List 'Projects' -Identity 3
HasUniqueRoleAssignments Permissions
------------------------ -----------
                    True {PowerShell Demo Owners, PowerShell Demo Visitors, PowerShell Demo Members}

For the permission levels as well as the names, walk the role assignments:

$item = Get-PnPListItem -List 'Projects' -Id 3
$assignments = Get-PnPProperty -ClientObject $item -Property RoleAssignments

$rows = foreach ($ra in $assignments) {
    $member = Get-PnPProperty -ClientObject $ra -Property Member
    $roles  = Get-PnPProperty -ClientObject $ra -Property RoleDefinitionBindings
    [pscustomobject]@{
        Principal = $member.Title
        Type      = $member.PrincipalType
        Roles     = ($roles | ForEach-Object { $_.Name }) -join ', '
    }
}
$rows | Format-Table -AutoSize
Principal                           Type Roles
---------                           ---- -----
PowerShell Demo Owners   SharePointGroup Full Control
PowerShell Demo Visitors SharePointGroup Read
PowerShell Demo Members  SharePointGroup Edit

The one to watch there is $rows = foreach. A foreach statement cannot be piped – only the ForEach-Object cmdlet can. Put a pipe straight after the closing brace and you get “An empty pipe element is not allowed”, which does not obviously point at the mistake you made.

A warning about fixing what you find

While writing this I tried to break inheritance on purpose to have something to detect:

Set-PnPListItemPermission -List 'Projects' -Identity 3 -InheritPermissions:$false

It reported success. It did nothing at all.

-InheritPermissions restores inheritance. There is no switch that breaks it, and passing $false is a silent no-op. If you write a script to lock something down that way, you get a green light and no change. Use the CSOM method instead:

$item = Get-PnPListItem -List 'Projects' -Id 3
$item.BreakRoleInheritance($true, $false)
Invoke-PnPQuery

First argument keeps the existing permissions so you do not lock yourself out, second clears sub-scopes.

The whole script

Parameters instead of hardcoded URLs, progress while it runs, and a CSV if you want one:

[CmdletBinding()]
param(
    [Parameter(Mandatory)] [string] $SiteUrl,
    [Parameter(Mandatory)] [string] $ClientId,
    [string] $OutputCsv,
    [int]    $MaxItems = 5000
)

$ErrorActionPreference = 'Stop'
Connect-PnPOnline -Url $SiteUrl -ClientId $ClientId -Interactive

$results = [System.Collections.Generic.List[object]]::new()

function Get-AccessSummary {
    param($ClientObject)
    try {
        $assignments = Get-PnPProperty -ClientObject $ClientObject -Property RoleAssignments
        $parts = foreach ($ra in $assignments) {
            $member = Get-PnPProperty -ClientObject $ra -Property Member
            $roles  = Get-PnPProperty -ClientObject $ra -Property RoleDefinitionBindings
            "$($member.Title) [$(($roles | ForEach-Object { $_.Name }) -join '/')]"
        }
        return ($parts -join '; ')
    }
    catch { return "could not read: $($_.Exception.Message)" }
}

Write-Host "Checking site..." -ForegroundColor Cyan
$web = Get-PnPWeb
$null = Get-PnPProperty -ClientObject $web -Property HasUniqueRoleAssignments
if ($web.HasUniqueRoleAssignments) {
    $results.Add([pscustomobject]@{
        Scope = 'Site'; Name = $web.Title
        Path = $web.ServerRelativeUrl; Access = Get-AccessSummary $web
    })
}

$lists = Get-PnPList | Where-Object { -not $_.Hidden }
$listNumber = 0

foreach ($list in $lists) {
    $listNumber++
    Write-Progress -Activity 'Scanning for broken inheritance' `
                   -Status "$($list.Title) ($listNumber of $($lists.Count))" `
                   -PercentComplete (($listNumber / $lists.Count) * 100)

    $null = Get-PnPProperty -ClientObject $list -Property HasUniqueRoleAssignments
    if ($list.HasUniqueRoleAssignments) {
        $results.Add([pscustomobject]@{
            Scope = 'List'; Name = $list.Title
            Path = $list.RootFolder.ServerRelativeUrl; Access = Get-AccessSummary $list
        })
    }

    if ($list.ItemCount -eq 0) { continue }
    if ($list.ItemCount -gt $MaxItems) {
        Write-Warning "Skipping items in '$($list.Title)' - $($list.ItemCount) exceeds -MaxItems $MaxItems."
        continue
    }

    # Asking the list for its items also gives us folders, and avoids
    # Get-PnPFolderItem returning the system Forms folder, which has no
    # backing list item and throws when you ask about its permissions.
    foreach ($item in Get-PnPListItem -List $list -PageSize 500) {
        $null = Get-PnPProperty -ClientObject $item -Property HasUniqueRoleAssignments
        if (-not $item.HasUniqueRoleAssignments) { continue }

        $isFolder = $item.FileSystemObjectType -eq 'Folder'
        $results.Add([pscustomobject]@{
            Scope  = if ($isFolder) { 'Folder' } else { 'Item' }
            Name   = if ($isFolder) { $item['FileLeafRef'] } else { $item['Title'] }
            Path   = $item['FileRef']
            Access = Get-AccessSummary $item
        })
    }
}

Write-Progress -Activity 'Scanning for broken inheritance' -Completed

if ($results.Count -eq 0) {
    Write-Host "`nNothing found. Everything in this site inherits its permissions." -ForegroundColor Green
    Disconnect-PnPOnline
    return
}

Write-Host "`nFound $($results.Count) object(s) with unique permissions.`n" -ForegroundColor Yellow

if ($OutputCsv) {
    $results | Export-Csv -Path $OutputCsv -NoTypeInformation -Encoding UTF8
    Write-Host "Written to $OutputCsv" -ForegroundColor Green
} else {
    $results | Format-Table Scope, Name, Path -AutoSize
    $results | Format-List Scope, Name, Access
}

Disconnect-PnPOnline

Save it as Get-BrokenInheritance.ps1 and run it:

.\Get-BrokenInheritance.ps1 `
    -SiteUrl "https://contoso.sharepoint.com/sites/Demo" `
    -ClientId "11111111-2222-3333-4444-555555555555" `
    -OutputCsv .\permissions.csv
Checking site...

Found 3 object(s) with unique permissions.

Scope  Name              Path
-----  ----              ----
Site   PowerShell Demo   /sites/Demo
Folder Archive           /sites/Demo/Project Documents/Archive
Item   Project 03        /sites/Demo/Lists/Projects/3_.000

Scope  : Folder
Name   : Archive
Access : Demo Owners [Full Control]; Demo Visitors [Read]; Demo Members [Edit]

If a site is clean it tells you so rather than printing an empty table and leaving you wondering whether it worked or you mistyped the URL 🙂

Read SharePoint Lists, Items and Files with PnP PowerShell

In this post, I am going to show you how to read lists, items and files with PnP PowerShell, and the handful of things that come back looking nothing like what you expected.

All of it was run against a real site on PnP.PowerShell 2.12.0. The output below is what my machine printed.

Listing lists

The first command everyone runs:

Get-PnPList | Measure-Object | Select-Object Count
Count
-----
   19

Nineteen, on a site where I had created three. The rest are SharePoint’s own. So you filter out the hidden ones:

Get-PnPList | Where-Object { -not $_.Hidden } | Select-Object Title, ItemCount, BaseTemplate
Title             ItemCount BaseTemplate
-----             --------- ------------
Departments               4          100
Documents                 0          101
Events                    0          106
Form Templates            0          101
Project Documents         9          101
Projects                 25          100
Site Pages                1          119
Style Library             0          101

Down to eight, but still only three of those are mine. Documents, Events, Form Templates, Site Pages and Style Library are all created by SharePoint and are not hidden.

There is no flag for “lists a human made”. If you want that, filter on what you know – by title, or by BaseTemplate where 100 is a generic list and 101 is a document library.

Reading one item

$item = Get-PnPListItem -List 'Projects' -Id 1
$item.FieldValues

A list with five columns of mine returned forty-five fields. Alongside Title, Budget and ProjectStatus you get ComplianceAssetId, MetaInfo, owshiddenversion, ScopeId, SMTotalFileStreamSize, WorkflowVersion and a couple of dozen more.

To read a value, index into the item with the internal name:

$item['Title']
$item['ProjectStatus']
$item['Budget']
Project 01
Not Started
1000

The important thing to note is that internal names are not display names. A column shown as Project Status can have an internal name of ProjectStatus, or Project_x0020_Status if it was created through the browser with a space in the name. Check before you guess:

Get-PnPField -List 'Projects' | Select-Object Title, InternalName

Not every field is a string

This is where exports go wrong. Look at what comes back for the built-in Author field:

Author   Microsoft.SharePoint.Client.FieldUserValue
Editor   Microsoft.SharePoint.Client.FieldUserValue

Person fields, lookup fields and taxonomy fields are objects. Pipe one straight into a CSV and that is the text you get in the file. You want the property off it:

$item['Author'].LookupValue    # display name
$item['Author'].Email
$item['Author'].LookupId

Same shape for lookups – LookupValue for the text, LookupId for the ID.

Ask for less

By default you pull every one of those forty-five fields for every item. Ask only for what you need:

Get-PnPListItem -List 'Projects' -Fields 'Title','ProjectStatus' -PageSize 500

And filter on the server rather than dragging everything down and filtering locally:

$caml = "<View><Query><Where><Eq><FieldRef Name='ProjectStatus'/><Value Type='Text'>Blocked</Value></Eq></Where></Query></View>"
Get-PnPListItem -List 'Projects' -Query $caml

I timed both against my 25 items. Filtering locally took 998ms, the CAML query 913ms. Barely a difference, and I am not going to pretend otherwise.

It matters at scale. Past 5,000 items the list view threshold turns the lazy version from slow into an outright error, and by then you are rewriting the script under pressure. Get in the habit while your lists are small.

Files, and the folder nobody asks for

Listing a library looks fine:

Get-PnPFolderItem -FolderSiteRelativeUrl 'Project Documents'
Name      Type
----      ----
Archive   Folder
Contracts Folder
Forms     Folder
Reports   Folder

I created three of those. Forms is SharePoint’s, and it is in every library on every site.

Which becomes a real problem the moment you go recursive:

Get-PnPFolderItem -FolderSiteRelativeUrl 'Project Documents' -ItemType File -Recursive
Old Contract.txt        /Project Documents/Archive/2024/Old Contract.txt
Statement of Work.txt   /Project Documents/Contracts/Statement of Work.txt
AllItems.aspx           /Project Documents/Forms/AllItems.aspx
Combine.aspx            /Project Documents/Forms/Combine.aspx
DispForm.aspx           /Project Documents/Forms/DispForm.aspx
EditForm.aspx           /Project Documents/Forms/EditForm.aspx
repair.aspx             /Project Documents/Forms/repair.aspx
template.dotx           /Project Documents/Forms/template.dotx
Thumbnails.aspx         /Project Documents/Forms/Thumbnails.aspx
Upload.aspx             /Project Documents/Forms/Upload.aspx
Monthly Report.txt      /Project Documents/Reports/Monthly Report.txt
Versioned Document.txt  /Project Documents/Reports/Versioned Document.txt

Twelve files, of which four are documents. The other eight are the library’s own form pages. If you are writing a report of “every file in this library”, or worse a script that deletes or moves things, filter Forms out:

Get-PnPFolderItem -FolderSiteRelativeUrl 'Project Documents' -ItemType File -Recursive |
    Where-Object { $_.ServerRelativeUrl -notmatch '/Forms/' }

File metadata without downloading the file

Get-PnPFile -Url "/sites/Demo/Project Documents/Reports/Versioned Document.txt" -AsListItem

-AsListItem gives you the metadata only. Without it you are pulling the bytes down, which you rarely want if all you needed was the modified date.

Version history is one shorter than you think

Get-PnPFileVersion -Url "/sites/Demo/Project Documents/Reports/Versioned Document.txt"
VersionLabel Created              Size
------------ -------              ----
1.0          7/21/2026 5:17:06 PM   30
...
11.0         7/21/2026 5:17:17 PM   31

Eleven versions. But the file itself reports 12.0 as its current version.

Get-PnPFileVersion returns the previous versions, not the current one. So if you are counting versions to work out storage, or deciding what to trim, remember the live copy is not in that list and cannot be deleted from it 🙂

Connect-PnPOnline: Every Way to Authenticate, and When to Use Each

In this post, I am going to go through every way Connect-PnPOnline can authenticate, which ones still work after the September 2024 changes, and which one you should use for what.

There are more than you think. On PnP.PowerShell 2.12.0 there are fifteen. You only need three of them.

See the list for your own version

Do not trust a blog post for this, including this one. The parameter sets change between releases and the documentation online describes whichever version was current when it was written. Ask your own machine:

Get-Command Connect-PnPOnline -Syntax

Or just the authentication-related parameters:

(Get-Command Connect-PnPOnline).Parameters.Keys |
    Where-Object { $_ -match 'Interactive|WebLogin|Device|Certificate|Secret|Managed|Token' } |
    Sort-Object

On 2.12.0 that gives:

AccessToken
CertificateBase64Encoded
CertificatePassword
CertificatePath
ClientSecret
DeviceLogin
Interactive
ManagedIdentity
UserAssignedManagedIdentityAzureResourceId
UserAssignedManagedIdentityClientId
UserAssignedManagedIdentityObjectId
UseWebLogin

The three you will actually use

Interactive – you, at a keyboard, signing in through a browser. This is the normal one.

Connect-PnPOnline `
    -Url "https://contoso.sharepoint.com/sites/Demo" `
    -ClientId "11111111-2222-3333-4444-555555555555" `
    -Interactive

Certificate – a scheduled script with nobody watching. No prompts, no password.

Connect-PnPOnline `
    -Url "https://contoso.sharepoint.com/sites/Demo" `
    -ClientId "11111111-2222-3333-4444-555555555555" `
    -Tenant contoso.onmicrosoft.com `
    -CertificatePath "C:\certs\PnP.pfx" `
    -CertificatePassword (ConvertTo-SecureString "yourpassword" -AsPlainText -Force)

Managed identity – running inside Azure Automation or an Azure Function. No secret to store anywhere, which is why it is the best option when you can use it.

Connect-PnPOnline -Url "https://contoso.sharepoint.com/sites/Demo" -ManagedIdentity

That is the whole decision. Interactive when you are there, certificate when you are not, managed identity when you are in Azure.

What stopped working in September 2024

Two things, and they produce the same unhelpful error.

Interactive with no ClientId:

Connect-PnPOnline -Url "https://contoso.sharepoint.com/sites/Demo" -Interactive
WARNING: Connecting with -Interactive used the PnP Management Shell multi-tenant
App Id for authentication. As of September 9th, 2024 this option is not available
anymore.

Specified method is not supported.

Username and password:

$cred = Get-Credential
Connect-PnPOnline -Url "https://contoso.sharepoint.com/sites/Demo" -Credentials $cred
WARNING: As of September 9th, 2024 the option to use the PnP Management Shell app
registration for authentication is not available anymore.

Specified method is not supported.

Both were leaning on the shared PnP Management Shell app registration that Microsoft deleted. Register your own and both come back to life – I covered that in Connect-PnPOnline: Specified method is not supported.

-UseWebLogin still works, and it is the only one that needs nothing

This surprised me. I expected it to be gone:

Connect-PnPOnline -Url "https://contoso.sharepoint.com/sites/Demo" -UseWebLogin
WARNING: Consider using -Interactive instead, which provides better functionality.

And then it connects. No ClientId, no app registration, nothing.

It works because -UseWebLogin never used the PnP Management Shell app in the first place. It signs you in with browser cookies, so the app being deleted did not affect it.

The important thing to note is that this is not a recommendation. It is deprecated, the warning is telling you so, it does not carry a Microsoft Graph token, and it will go eventually. But if you have inherited a broken script, need it working in the next ten minutes, and cannot get an app registration approved today, this is your bridge.

Then go and register the app properly.

Device login needs one extra parameter

Device login prints a code, you enter it at microsoft.com/devicelogin on any device, and the script continues. It is what you use on a machine with no browser, or over a remote session.

If you pass -ClientId you must also pass -Tenant, or you get this:

Please specify -Tenant with either the tenant id or hostname.
Unable to connect using provided arguments

So:

Connect-PnPOnline `
    -Url "https://contoso.sharepoint.com/sites/Demo" `
    -ClientId "11111111-2222-3333-4444-555555555555" `
    -Tenant contoso.onmicrosoft.com `
    -DeviceLogin

The rest

The other parameter sets are real but narrow:

  • -AccessToken – you already got a token some other way and just want PnP to use it.
  • -ClientSecret – app-only with a secret instead of a certificate. Works for some endpoints, but certificates are the supported path for SharePoint.
  • -EnvironmentVariable – reads the client ID from an environment variable so it is not sitting in your script.
  • -OSLogin – uses the account you are already signed into Windows with.
  • -AzureADWorkloadIdentity – for workloads running in Kubernetes.
  • -SPOManagementShell and -CurrentCredentials – legacy, do not start anything new with these.

If you are writing something new, use interactive while you build it and switch to a certificate when you schedule it. That covers almost everything 🙂

Connect-PnPOnline: Specified Method Is Not Supported

You run a PnP PowerShell script that worked fine for years, and you get this:

Connect-PnPOnline: Specified method is not supported.

Or, depending on which version of the module you are on, this one:

AADSTS700016: Application with identifier '31359c7f-bd7e-475c-86db-fdb8c937548e'
was not found in the directory.

Both mean the same thing. Nothing is wrong with your script. The app registration it was quietly relying on does not exist any more.

What happened

Until September 2024, PnP PowerShell shipped with a multi-tenant Entra ID app called PnP Management Shell, and every tenant could use it without registering anything. That is the app ID in the error above. The PnP team announced on 21 August 2024 that it was going away, and deleted it on 9 September 2024.

So Connect-PnPOnline -Url ... -Interactive with no -ClientId has nothing to authenticate against. You now need your own app registration in your own tenant.

This is a good change, even though it broke everyone’s scripts on the same day. That shared app had far more permissions than any one script needed.

The fix – register your own app

PnP gives you a cmdlet for this, so you do not have to click through the Entra portal. Run this once per tenant:

Register-PnPEntraIDAppForInteractiveLogin `
    -ApplicationName "PnP.PowerShell" `
    -Tenant contoso.onmicrosoft.com `
    -Interactive

The one to watch here is -Interactive. On PnP.PowerShell 2.x you must include it, or you get this instead:

Register-PnPEntraIDAppForInteractiveLogin: Parameter set cannot be resolved
using the specified named parameters.

Which is not a helpful message when you are already fixing a different error. Check what you are on with Get-Module PnP.PowerShell -ListAvailable, and if you are ever unsure of the parameters for your version, Get-Command Register-PnPEntraIDAppForInteractiveLogin -Syntax tells you rather than the documentation, which describes whichever version is current.

You will then see a warning that no permissions were specified so defaults are being used, a line confirming the app was created with its ID, and a 30 second pause while Entra ID catches up before the consent window opens. Sign in and accept.

When it finishes it prints a Client ID – copy that, you need it every time you connect from now on.

The important thing to note is you need to be a Global Administrator or Application Administrator to run this, because it is creating an app registration and granting consent. If you are not, this is the point where you go and talk to whoever is.

Then connect like this:

Connect-PnPOnline `
    -Url "https://contoso.sharepoint.com/sites/Demo" `
    -ClientId "11111111-2222-3333-4444-555555555555" `
    -Interactive

That is it. Every script you have needs that one extra parameter.

If your script runs unattended

Interactive login is no good for anything scheduled. For that you want app-only with a certificate:

Register-PnPEntraIDApp `
    -ApplicationName "PnP.PowerShell.Unattended" `
    -Tenant contoso.onmicrosoft.com `
    -OutPath C:\certs `
    -DeviceLogin

That writes a certificate to C:\certs. Then:

Connect-PnPOnline `
    -Url "https://contoso.sharepoint.com/sites/Demo" `
    -ClientId "11111111-2222-3333-4444-555555555555" `
    -Tenant contoso.onmicrosoft.com `
    -CertificatePath "C:\certs\PnP.PowerShell.Unattended.pfx" `
    -CertificatePassword (ConvertTo-SecureString -String "yourpassword" -AsPlainText -Force)

Keep that certificate somewhere sensible. Anyone who has the file and the password has whatever permissions you granted the app.

Two things that catch people out

You still get an error after registering. Nine times out of ten the app is missing a permission, or the permission is there but nobody clicked Grant admin consent. Open the app in Entra admin center > App registrations > API permissions and check for the warning triangle.

Both cmdlets only add a small default set of permissions. If your script does something beyond reading sites, add what you need explicitly:

Register-PnPEntraIDAppForInteractiveLogin `
    -ApplicationName "PnP.PowerShell" `
    -Tenant contoso.onmicrosoft.com `
    -SharePointDelegatePermissions "AllSites.FullControl" `
    -GraphDelegatePermissions "User.Read.All"

Register once, put the Client ID somewhere your scripts can read it, and you are back to writing PowerShell instead of fixing it 🙂

If you are updating old scripts, my earlier post on copying SharePoint Online web part settings with PnP PowerShell is one of the ones I had to go back and fix – the connection line is the only part that changed.

Install PnP PowerShell and Connect to SharePoint Online

In this post, I am going to show you how to install PnP PowerShell and connect to SharePoint Online, and how to check the connection actually worked before you start running anything against it.

Everything below was run on PowerShell 7.5.8 with PnP.PowerShell 2.12.0. The output is what my machine printed, not what the documentation says it should print.

Install the module

Install-Module PnP.PowerShell -Scope CurrentUser

-Scope CurrentUser installs it under your profile so you do not need to run PowerShell as administrator.

If you have had PnP installed for a while you may get this instead of an install:

WARNING: Version '2.12.0' of module 'PnP.PowerShell' is already installed at
'C:\Program Files\WindowsPowerShell\Modules\PnP.PowerShell\2.12.0'. To install
version '3.3.0', run Install-Module and add the -Force parameter.

That is not an error. It found an older copy installed for all users and stopped rather than silently putting a second one next to it. Add -Force if you do want the newer version side by side.

Check what you have with:

Get-Module PnP.PowerShell -ListAvailable | Select-Object Name, Version, ModuleBase
Name           Version ModuleBase
----           ------- ----------
PnP.PowerShell 2.12.0  C:\Program Files\WindowsPowerShell\Modules\PnP.PowerShell

Worth doing before anything else, because the syntax of several cmdlets changed between 2.x and 3.x and half the guides you will find online do not say which one they were written for.

You need an app registration first

This is the part that catches everyone coming back to an old script. You cannot just connect any more:

Connect-PnPOnline -Url "https://contoso.sharepoint.com/sites/Demo" -Interactive
WARNING: Connecting with -Interactive used the PnP Management Shell multi-tenant
App Id for authentication. As of September 9th, 2024 this option is not available
anymore.

Specified method is not supported.

The red line is the one everybody pastes into Google. The answer is in the yellow warning just above it, which most people scroll straight past.

Register your own app once per tenant:

Register-PnPEntraIDAppForInteractiveLogin `
    -ApplicationName "PnP.PowerShell" `
    -Tenant contoso.onmicrosoft.com `
    -Interactive

On 2.x you must include -Interactive or you get an unhelpful “Parameter set cannot be resolved”. I wrote that up separately in Connect-PnPOnline: Specified method is not supported.

Copy the Client ID it gives you at the end. You need it every single time from now on.

Connect

Connect-PnPOnline `
    -Url "https://contoso.sharepoint.com/sites/Demo" `
    -ClientId "b1539324-4fa0-4bf0-8c45-949d49fbe112" `
    -Interactive

Your browser opens, you sign in, and the command finishes with no output at all. That is normal – PnP tells you nothing when it works.

Check it actually connected

Because it says nothing on success, get into the habit of confirming:

Get-PnPWeb | Select-Object Title, Url, Id
Title : PowerShell Demo
Url   : https://contoso.sharepoint.com/sites/Demo
Id    : cfa3cc52-ce17-4c52-a7b8-89ae0c4f66e6

And if you want to see what you are connected as:

Get-PnPConnection | Select-Object Url, ConnectionType, ClientId
Url            : https://contoso.sharepoint.com/sites/demo
ConnectionType : O365
ClientId       : a0748433-4fa0-4bf0-8c45-949d49fbe112

The first thing everyone runs, and the first surprise

Get-PnPList | Select-Object Title, ItemCount, Hidden

On a site where I had made exactly four lists, that returned nineteen:

Title                 ItemCount Hidden
-----                 --------- ------
appdata                       0   True
Composed Looks               18   True
Departments                   4  False
Documents                     0  False
Master Page Gallery         175   True
Project Documents             9  False
Projects                     25  False
Site Pages                    1  False
Style Library                 0  False
TaxonomyHiddenList            0   True
Theme Gallery                41   True
User Information List         9   True
Web Part Gallery              6   True

Most of those are system lists SharePoint creates for itself. You almost always want:

Get-PnPList | Where-Object { -not $_.Hidden } | Select-Object Title, ItemCount

The same thing happens with folders. Listing a document library gives you a Forms folder you did not create:

Name      Type
----      ----
Archive   Folder
Contracts Folder
Forms     Folder
Reports   Folder

Forms holds the library’s form pages and is in every library on every site. If you are looping through folders to do something, filter it out or your script will try to process it.

Disconnect when you are done

Disconnect-PnPOnline

Not strictly required, but if you are switching between tenants in one session it saves you a confusing half hour wondering why your commands are hitting the wrong site ?