Check which SharePoint sites are restricted from Copilot with PowerShell

You spent last month turning Restricted Content Discovery on, site by site, to keep the messy ones out of Copilot. Now someone asks a fair question: which sites did we actually restrict? Clicking through Active sites one at a time is not the answer. PowerShell gives you two clean ways to get it, one site at a time and the whole tenant at once, and the tenant one is a command almost nobody knows exists.

First, licensing, so you do not chase an error: Restricted Content Discovery is a SharePoint Advanced Management feature, so these commands need SAM, which you have if anyone in the tenant holds a Microsoft 365 Copilot licence. You also need the SharePoint Online Management Shell and the SharePoint Administrator role.

Check one site

The setting lives on the site as a property called RestrictContentOrgWideSearch. For a single site, read it straight off:

Get-SPOSite -Identity "https://contoso.sharepoint.com/sites/Finance" |
    Select-Object Url, RestrictContentOrgWideSearch

[LAB: paste the real one-line output here, showing True/False for a restricted site]

True means the site is restricted from Copilot and organisation-wide search. False means it is not. Simple enough for one site. The problem is the other four hundred.

Check the whole tenant

You might reach for Get-SPOSite -Limit All and try to select the property across every site in one go. [LAB: confirm whether Get-SPOSite -Limit All | Select Url, RestrictContentOrgWideSearch actually returns the value, or comes back blank in list mode. If blank, say so here, because that is the gotcha worth documenting.]

Either way, Microsoft gives you a purpose-built report for this, and it is the right tool. It runs in three steps, because the report is generated asynchronously. Kick it off:

Start-SPORestrictedContentDiscoverabilityReport

Check on it, and grab the report id once it is ready:

Get-SPORestrictedContentDiscoverabilityReport

Then download it:

Get-SPORestrictedContentDiscoverabilityReport -Action Download -ReportId <ReportGUID>

[LAB: describe what the downloaded report actually contains – the columns, one row per restricted site – and paste a small, sanitised sample. Never real tenant site names; use the demo site.]

The report lands in the folder you ran the command from. That is your list: every site currently restricted from Copilot, in one file, without touching the admin center.

The half this does not answer

This tells you what you have hidden. It does not tell you what you should have hidden, which is a different question entirely: which sites are actually overshared. Restricted Content Discovery is the cover you throw over a site while you sort the access out; the report above just confirms where the cover is.

Working out which sites need it in the first place, the who-can-reach-what, is the harder half. That is the part I built User Access Explorer to show.

DLP for Copilot: the last lever for keeping content out of Copilot

Everything so far has been about sites and encryption. Restricted Content Discovery hides a whole site from Copilot. A sensitivity label with encryption locks a single file. But two things fall straight through those gaps. The Confidential document that is not encrypted, sitting on a site you never restricted, which Copilot folds into a summary without a second thought. And the user who pastes a customer’s card number into a Copilot prompt to “just check something”. Neither is a site problem, neither is an encryption problem, and this is the control that was built for both: Data Loss Prevention for Copilot.

What it is

It is a Microsoft Purview DLP policy with a location called Microsoft 365 Copilot and Copilot Chat. You will only find it under the Custom policy template, and the moment you switch that location on, every other DLP location in the policy switches off. This one stands on its own.

Keeping labelled content out of answers

The headline job is keeping labelled content out of Copilot’s responses. You add a rule with the condition Content contains > Sensitivity labels, pick your labels, say Highly Confidential and Personal, and set the action to Prevent Copilot from processing content. From then on, Copilot will not pull the contents of a file carrying those labels into a response.

Here is the part that makes it different from everything before it. The trigger is the label, not the encryption. So the unencrypted Confidential file, the one the EXTRACT usage right could never touch because there was no encryption to hang a right on, is now excluded all the same. And Copilot stays honest about it: the item can still appear in the response’s citations, so the user sees that something was deliberately held back rather than quietly missing.

Three more things the same location does

Blocking labelled files is what most people come for, but the Copilot location does three more jobs worth knowing.

It can stop a prompt in its tracks when the user types a sensitive information type into it, a credit card or a passport number, so Copilot simply refuses to answer. It can keep those same sensitive types from being sent out to external web search, while still answering from your internal data. And, in preview, it can drop emails from external senders out of Copilot’s grounding entirely, which is a quiet but real defence against a prompt injection arriving by email.

What will trip you

A few things bite if you do not know them. You cannot put a sensitive-information-type condition and a sensitivity-label condition in the same rule; make two rules in the one policy instead. The email coverage only reaches messages from 1 January 2025 onward, and calendar invites are not covered at all. Changes take up to four hours to show in Copilot, so do not test it in the first five minutes. And there is a simulation mode, which you should absolutely use before you turn any of this on for real.

Where it sits, and the one thing it still does not do

So now you have the whole set. A label with encryption stops Copilot reading a file. Restricted Content Discovery hides a site. Restricted Access Control locks a site to a group. DLP is the finest-grained of the four, the only one that works off the label without needing encryption, and the only one that acts on the prompt itself in real time.

One thing has been true of every single one of them, and it is worth saying plainly. None of these change who can open the file. A DLP rule stops Copilot summarising the document; the person it was overshared to can still open it and read every word. The controls decide what Copilot may do. They do not decide who has access. That question, the one sitting underneath all four of these posts, is still yours, and it is what I built User Access Explorer to answer.

Does Microsoft 365 Copilot copy your data to answer you?

Propose Copilot to a security-minded colleague and you get the same question within about thirty seconds. “So it makes a copy of all our data? Where does that copy live? Who else can see it?” The mental picture is Copilot hoovering up every file in the tenant into some giant AI brain in the sky. The reality is a lot more boring, and a lot more reassuring. Here is what actually happens.

The short answer

Copilot does not make a copy of your documents to answer you. It reads your content in place, through Microsoft Graph, the same plumbing that already powers search across Microsoft 365. When you ask something, it goes and finds the relevant material you already have access to, uses it to answer, and moves on. Your files stay exactly where they are.

But it does build an index, so isn’t that a copy?

This is the part that trips people, because it is technically true that Copilot builds something out of your data. It is called the semantic index, and the word “index” is the one that matters. It is not a copy of your files. It is a mathematical representation of them.

Microsoft turns your text content into vectors  numbers that capture meaning  so that “the vendor did great design work” and “the supplier’s artwork was excellent” land close together even though they share no words. That is what lets Copilot answer a fuzzy question well. But a pile of vectors is not your document. You cannot reassemble the payroll spreadsheet from the index any more than you can rebuild a song from its search tags.

And that index never leaves your tenant. In Microsoft’s own words, the data generated by indexing remains within your company’s tenant. The user-level index sits where your mailbox lives; the tenant-level index sits in an isolated container in your own tenant’s region. It doesn’t even count against your storage quota. And it honors the same permissions as everything else, so it only ever helps surface content you were already allowed to open.

What actually happens when you ask

Here is the full sequence when you type a prompt, straight from Microsoft’s documentation:

  1. Your prompt goes from the app to Copilot.
  2. Copilot checks Microsoft Graph and the semantic index for relevant, permission-trimmed context, and appends what it finds to your prompt.
  3. That grounded prompt goes to the language model.
  4. The model’s response comes back.
  5. Copilot runs a post-processing pass, adds citations, and returns the answer to your app.

The content pulled in at step 2 is used to answer, and then it is done. It is grounding for that one question, not a copy squirrelled away somewhere.

So what is stored?

Something is kept, and it is worth being precise about what. Copilot stores the conversation — your prompt and its response, with citations — as your Copilot activity history. That is encrypted, stored alongside your other Microsoft 365 content, discoverable and retainable through Microsoft Purview, and you can delete it yourself from the My Account portal.

Read that again: what is stored is the chat, not a second copy of your source files. The spreadsheet Copilot cited still lives in its one place in SharePoint. The record is simply that you asked about it and what Copilot said back.

Three things that usually settle the room

  • None of it trains the AI. Your prompts, your responses, the data pulled from Graph, and the semantic index itself are not used to train the foundation models. Microsoft states this flatly, in more than one place.
  • No human is reading it. Azure OpenAI has an abuse-monitoring feature that includes human review of content. Microsoft 365 Copilot has explicitly opted out of it.
  • It stays in your boundary. The data honors your tenant isolation and residency commitments, including the EU Data Boundary for EU customers.

So where is the actual risk?

If the data handling is this clean, what is left to worry about? The one thing all of it was careful to preserve: permissions. The index respects them. Grounding respects them. Copilot only ever surfaces what a person could already open.

Which means Copilot was never going to leak your data by copying it. It will, however, faithfully surface anything you already overshared, to whoever you overshared it with, the moment they ask. You can pull a genuinely sensitive site out of the index — by turning off its search visibility or excluding it with a DLP policy — but notice what that is: hiding, not fixing. The people who could already reach it still can.

That is the whole story. Copilot does not copy your data. It indexes it as vectors inside your own tenant, searches it in place, shows each person only what they could already access, keeps the conversation rather than your files, and trains nothing. The question worth losing sleep over was never where your data goes. It is who can already reach it — and that part is still yours to answer. It is what I built User Access Explorer to show.

Sources

Every claim above comes from Microsoft’s own documentation. If you want to read the primary material:

Microsoft 365 Copilot vs Glean: the permissions question that actually decides it

Sooner or later, someone in a Microsoft shop asks it out loud: should we just buy Glean instead of turning on Copilot? Usually the thinking is that one of them must be safer with company data than the other. It is a fair question, and the honest answer is not the one either vendor’s deck gives you.

Both are enterprise AI that answers questions over your own company content. Microsoft 365 Copilot does it from inside Microsoft 365, grounded on your SharePoint, OneDrive, Exchange and Teams, reaching outside through Graph connectors. Glean does it across the whole app estate, pulling from Google Drive, Slack, Salesforce, Jira, Confluence, Microsoft 365 and the rest through its own connectors. One is deep in one place. The other is broad across many. Hold that thought, because it is the real difference and we will come back to it.

The thing they do identically

First, the part people get wrong. Both are permission-aware. Neither invents access.

Glean pulls the permission map, the access-control list, from every source it connects to, and filters what it retrieves down to what you personally can see. In their own words, if you do not have access to a document in Google Drive or a channel in Slack, it never appears in your Glean results and never lands in an answer it writes for you.

Copilot behaves the same way inside Microsoft 365. Every prompt runs in your security context. If you cannot open a file in SharePoint, Copilot will not summarise it for you. It only ever surfaces what you already had permission to reach.

So on the question people actually lose sleep over, “will it hand data to someone who should not see it”, both give the same answer: no, not beyond what your permissions already allow.

The mess both of them inherit

And there is the catch neither deck leads with. Permission-aware cuts both ways. It faithfully reproduces your access model, mess and all.

A document shared with Everyone is a document both of them will happily hand to everyone, because everyone genuinely has permission. A folder a departing employee opened up three years ago is fair game the moment somebody asks the right question. Neither tool overshares. Your tenant already did. The assistant just takes what was buried under a thousand folders and makes it instantly reachable, in plain language, to anyone who thinks to ask.

This is why switching from one to the other fixes nothing about safety. If your SharePoint is overshared, Copilot surfaces it. If your Drive and Slack are overshared, Glean surfaces it. The assistant is only ever as clean as the permissions underneath it, and picking a different assistant does not clean them.

 

So what actually decides it

If safety is a wash, two things are left, and these are the ones to argue about.

Reach. Copilot is native and deep in Microsoft 365, so if that is where your knowledge lives, it needs nothing extra to be useful, with Graph connectors extending it outward when you need them. Glean’s whole pitch is breadth: if your organisation’s knowledge is scattered across a dozen SaaS apps, Glean was built to index all of it in one place and answer across the lot. A heavily Microsoft shop leans one way. A best-of-breed-SaaS shop leans the other. This, not security, is the honest deciding line.

The governance around it. Microsoft does not just hand you Copilot, it hands you the machinery to manage the oversharing Copilot exposes: Purview for data loss prevention and sensitivity labels, SharePoint Advanced Management for Restricted Content Discovery, Restricted Access Control, and the Data Access Governance reports. Glean brings its own model, single-tenant connectors, permission sync kept current across every app, least-privilege enforcement, and zero-retention agreements with the model providers so your data is never used for training. Both are serious. They are serious in different shapes: one is a governance stack you assemble around the assistant, the other is a posture baked into the platform.

The honest verdict

So it is not “which is safer”, because on the answer that matters they behave the same. It is “where does your knowledge actually live, and how much Microsoft governance do you already own”. A Microsoft-centric org already holding Purview and SharePoint Advanced Management has most of the toolset and the native depth in hand. An organisation whose real knowledge is spread across non-Microsoft SaaS has a genuine case for Glean’s breadth.

But the decision underneath both, the one that actually determines whether either is safe to switch on, is the same no matter which logo you pick. They surface what people can already reach. So the work is not choosing the assistant. The work is fixing the oversharing first, so that when you do turn one on, it has nothing embarrassing to find.

That part, the who-can-currently-reach-what across your Microsoft estate, is what I built User Access Explorer to show. Whichever assistant you land on, do that first.

Sensitivity labels and Copilot: which ones actually keep content out

A user opens a protected document, reads it without any trouble, and then asks Copilot to summarise it. Copilot refuses. It just hands back a link to the very file they already have open. So they raise a ticket saying Copilot is broken. It is not broken. It is doing one thing right that almost nobody knows about.

How Microsoft 365 Copilot behaves around sensitivity labels comes down to a single usage right you have probably never looked at, called EXTRACT. Once that one idea lands, the rest of it stops being confusing.

VIEW lets you read. EXTRACT lets Copilot summarise.

When a sensitivity label applies encryption, it grants each person a set of usage rights. Two of them matter here. VIEW is the right to open the file and read it. EXTRACT, which shows up in the Purview portal as “Copy and extract content” and carries the friendly name Copy, is the right to copy text out of it.

Copilot needs EXTRACT. Not VIEW, EXTRACT. In Microsoft’s own words, if the content grants a user VIEW but not EXTRACT, Copilot will not summarise it and can only point to it with a link.

So the user in that ticket had VIEW and not EXTRACT. They could read the file with their own eyes all day. Copilot, which works by pulling the text out, was never allowed to. Same file, same person, two different answers, and both are correct.

One exception is worth knowing: whoever applied the encryption always has EXTRACT, because they own it. So a file you protected yourself will always come back to you in Copilot. The restriction is for everybody else.

So here is the switch

Turn that around and you have the cleanest control you will find. If you want a class of content genuinely out of Copilot’s reach, use a sensitivity label that applies encryption without the Copy (EXTRACT) right. People who need to can still open and read. Copilot cannot summarise it, quote it, or carry the text into anything it generates. You have not taken access away from anyone, you have taken it away from the machine.

The stronger settings go further. Content labelled with user-defined permissions is off-limits to Copilot and agents entirely while it sits unopened in SharePoint or OneDrive. And Double Key Encryption, the one meant for your most sensitive material, Copilot cannot touch at all.

Now the trap

Here is where people get a false sense of safety. You put a Confidential label on the site, assume everything inside it is now hidden from Copilot, and move on. It is not. A label applied to a container, a SharePoint site or a Microsoft 365 group, is not inherited by the files inside it. Copilot does not see the site’s Confidential label on the document, and the document carries no protection of its own. As far as Copilot is concerned, the label on the site does nothing at all for the files.

If you want the files protected, the label has to be on the files. The site label is for the site.

What Copilot writes carries the label forward

There is a nice half to this. When Copilot builds new content out of labelled sources, it inherits the highest-priority label among them. Summarise three documents where the most sensitive is Confidential, and the summary itself comes out Confidential. The protection follows the content into whatever Copilot produces, which is exactly the behaviour you would want.

How to check one file in ten seconds

If you are ever not sure whether a document you can open is also readable by Copilot, open it in the Windows Office app and add Permissions to the status bar. Click the icon next to the label name, look at My Permission, and read the value for Copy. Yes means EXTRACT, which means Copilot can use it. No means it cannot.

The one thing labels do not do

Labels decide what Copilot is allowed to do with a file. They do not decide who can open it. A document shared with Everyone is still shared with Everyone; the label only stops Copilot from summarising it for them. So a label is a good lever, and it is not a substitute for fixing who has access in the first place. If you also want to block Copilot from summarising specific labelled files without touching their encryption, a Purview DLP policy for the Copilot location can do that, but the EXTRACT right is the idea that explains everything else.

The access question is still yours to sort out. If that is where you are stuck, I wrote a free tool for it: User Access Explorer.

Restricted Content Discovery vs Restricted SharePoint Search: what actually hides content from Copilot

An admin switches on Restricted SharePoint Search to keep Copilot away from the messy sites nobody has cleaned up yet, tells the boss it is handled, and one week later the help desk is full of “I cannot find anything in search now” tickets. Two features sound like they do the same job, and picking the wrong one is exactly how you land in that week.

So there are two of them: Restricted SharePoint Search and Restricted Content Discovery. Both keep content out of org-wide search and out of Microsoft 365 Copilot. But they are not the same tool, they are not meant for the same job, and as of this month one of them is on its way out.

Restricted SharePoint Search: a blanket over the whole tenant

Switch it on, and org-wide search and Copilot see only the sites you put on an allowed list (up to 100), plus each user’s own OneDrive, the sites they visit often, and the files that were shared with them or that they recently touched. Everything else drops out of tenant-wide discovery. Not deleted, not locked, just invisible to search.

Microsoft has always called this a temporary measure, and now they are ending it. From 31 July 2026, you can no longer turn Restricted SharePoint Search on for the first time, and the official word is to move to Restricted Content Discovery instead. If you already have it on, the plan is the one it always was: use it to buy time, fix the oversharing underneath, and then switch it off.

So it was never meant to stay. A tenant where search only ever returns 100 sites is a tenant where people quietly stop using search, and Microsoft’s own advice is to disable it once you have cleaned up.

Set-SPOTenant -EnableRestrictedSharePointSearch $true

The allowed list, up to 100 sites, you curate with Add-SPOTenantRestrictedSearchAllowedList, Remove-SPOTenantRestrictedSearchAllowedList and Get-SPOTenantRestrictedSearchAllowedList, passing a site URL or a CSV of them.

Restricted Content Discovery: a switch on one site

This is the one Microsoft is now pointing everyone to. It is per-site. Turn it on for a site, and only that site’s content drops out of org-wide search and Copilot. Every other site stays exactly as it was. No allowed list, no tenant-wide blanket, just a flag on the sites that really need it.

Set-SPOSite -Identity "https://contoso.sharepoint.com/sites/Finance" `
    -RestrictContentOrgWideSearch $true

# read it back
Get-SPOSite -Identity "https://contoso.sharepoint.com/sites/Finance" |
    Select-Object Url, RestrictContentOrgWideSearch

There is a licensing catch. Restricted Content Discovery is a SharePoint Advanced Management feature, so you need SharePoint Advanced Management to use it, which you get either by having Microsoft 365 Copilot licensed (even a single user counts) or by buying the add-on. Restricted SharePoint Search needed no such thing, and that is the main reason the blunt one was what most tenants reached for first. Now that it is retiring, the licensed per-site switch is what is left.

The difference that actually matters

So here is the whole thing in one line. Restricted SharePoint Search decides what is discoverable and hides everything else. Restricted Content Discovery decides what is not discoverable and leaves everything else alone. One is opt-in for the entire tenant, the other is opt-out for a handful of sites.

The blanket was for the panic phase, when Copilot is going live tomorrow and you have audited nothing. The per-site switch is for afterwards, when you already know which three sites hold the sensitive material and you want them out of Copilot without breaking search for everybody else. Microsoft has now made that choice for you: the blanket is being taken away, and the switch is what remains.

What neither of them does

Now the part people miss, and Microsoft is blunt about it in its own docs: neither feature is a security boundary, and neither changes who can open the content. That Everyone-except-external claim on the finance site is still sitting there. That organisation-wide sharing link still works. Anyone who already had access still walks straight in, search inside the site still finds everything, and while the blanket is on, Copilot will still surface a site to a user who recently visited it. You have not fixed the oversharing at all. You have only stopped Copilot from putting a spotlight on it in front of the whole company.

That is a reasonable thing to do while you clean up. It is a bad thing to mistake for the cleanup. The access is the real problem; discovery is only the symptom that Copilot made loud.

So the order is simple, and it is the order Microsoft itself recommends. Find the oversharing first, decide which sites genuinely need to stay hidden, and use Restricted Content Discovery on those. These controls only buy you the time to do it. They do not do it for you.

I wrote a free tool for the finding-the-oversharing part, if that is where you are stuck: User Access Explorer.

User Access Explorer: a tool to see what a user can reach in SharePoint

Someone in your tenant opens Copilot, asks it to pull together last quarter’s numbers, and it hands back a figure from a workbook they were never meant to open. The permission was sitting there the whole time. Nobody caught it because SharePoint has no single screen that answers the one question an admin actually asks: what can this person get to, and how did they get it?

I got tired of stitching that answer together by hand, so I built a small tool for it. It is called User Access Explorer, it is free, MIT-licensed, and it lives here: github.com/gvijaikumar9/UserAccessExplorer.

What it actually does

You pick a user. The tool walks the sites they can reach and shows every route that gets them in, one row per route. A route is the a direct grant, a SharePoint group, an Entra (Microsoft 365) group, an Everyone or Everyone except external users claim, or a sharing link.

The routes that matter get pushed to the top. The tool splits access into Granted (someone deliberately gave it) and Overshared (a claim or a link handed it out to more people than anyone intended). Overshared is Microsoft’s own word for it in the Copilot and SharePoint Advanced Management reports, so I kept it. That Overshared bucket is the whole point of the tool. It is the list you hand to whoever owns the site and say “explain this one”.

A couple of things I learned building it, because they trip up most home-grown permission scripts:

  • Contributor is not enough. Reading permissions needs Full Control. A script running as a plain contributor will return confident, wrong answers because it simply cannot see the role assignments. For one site you need Site Owner or Site Collection Admin. For the whole tenant you need an app registration with Sites.FullControl.All, because being a tenant admin does not make you site admin everywhere.
  • The scope in a sharing-link group name is a lie. A link shows up internally as a group called SharingLinks.<guid>.Flexible.<id>, and that word “Flexible” tells you nothing about who the link reaches. Only Get-PnPFileSharingLink gives you the real scope. The tool always asks it.
  • A sharing link is not a per-user grant. If you only ask “what can this user do on this file”, a link never shows up, no matter how deep you crawl, because the link grants a hidden group and not the person. So the tool answers a second, separate question on every shared item: does this link’s audience actually include the user? Anonymous and Organization links reach them; a Users link only if their name is on it.

What you need

  • PowerShell 7.2 or newer.
  • PnP.PowerShell 2.12 or newer (Install-Module PnP.PowerShell).
  • An Entra app registration you can sign in with interactively. If you do not have one, the next step makes one. The GUI also has a “Register one for this tenant” link in the connect box that does the same thing.
  • For the extras: User.ReadBasic.All so the people picker can search, and GroupMember.Read.All so it can confirm group membership instead of guessing.

Register the app

Skip this if you already have an app registration with SharePoint Full Control. If not, PnP will create one for you and walk you through consent in the browser:

Register-PnPEntraIDAppForInteractiveLogin `
    -ApplicationName "User Access Explorer" `
    -Tenant contoso.onmicrosoft.com `
    -Interactive

It opens a sign-in window and asks you to grant consent. The one thing to get right is the consent: this tool reads permissions, so it needs Sites.FullControl.All, and for the people picker and membership checks it needs the Graph scopes User.ReadBasic.All and GroupMember.Read.All. Granting that consent needs a tenant admin, so if that is not you, send them the link the command prints.

When it finishes it prints the new Client ID. Copy it, you paste it into the tool in a moment.

Install it

git clone https://github.com/gvijaikumar9/UserAccessExplorer.git
cd UserAccessExplorer
Import-Module .\UserAccessExplorer.psd1

That gives you the PowerShell commands. For the app, run the GUI script:

pwsh -File .\gui\Show-UserAccessExplorer.ps1

Using the app

The connect box opens on launch. Paste your app’s Client ID and your tenant admin URL (the ...-admin.sharepoint.com one), sign in, and the chip at the top right turns green with your tenant name.

From there:

  1. Start typing a name or email in User. It searches after the second letter and drops the matches below, so you pick rather than type the whole thing.
  2. Choose a Scope. One site checks a single site. One site (deep) walks that site’s subsites, lists and items that have their own broken-inheritance permissions, which is where oversharing hides. Whole tenant sweeps every site, and it is slow, so it runs in the background with a progress count and a Stop button.
  3. Hit Scan.

The four tiles across the top give you the shape of it at a glance: how many routes, how many are Overshared (the tile goes pink when that is above zero), how many sites, and the highest level of access the person holds anywhere. The grid underneath is one row per route, with a Grant path column reading like Everyone claim -> Read or Marketing Members -> Edit. Click any column header to sort, or use the chevron to group or filter. The small link icon in the Site column opens the exact page in SharePoint where that access is managed, so fixing it is one click away.

 

Comparing two people

Flip the Single user label to bring in a second user, and the scan runs both and diffs them. You get three buckets: Shared by both, Only the first person, and Only the second. This is the one I reach for when someone says “give the new starter the same access as her manager” and I want to see exactly where the two differ before copying anything.

Every finished scan is saved under Scan history, so you can reopen it instantly later without hitting SharePoint again, and there is an Export button that writes a self-contained HTML report you can forward.

Turn it around: who can reach a site

There is a mirror image of that question, and it matters just as much. Pick a site instead of a person, and see who can reach it, and how. Flip the By user / By site switch in the header and the subject becomes the site.

Now every row is a principal that can get in: a user, a SharePoint group, an Entra group, an Everyone claim or a sharing link, with the Overshared ones sorted to the top again. Groups carry a member count, so Marketing Members (2) and Marketing Members (340) are easy to tell apart. Run it deep and it walks the subsites, libraries and files that have their own permissions, so a document shared with Everyone three folders down gets its own row.

 

This is the view to run before you turn Copilot loose on a site. The question stops being whether one person can see something and becomes who can see it, how many of them there are, and whether anyone meant for them to.

To view scan history and reports

If you prefer the command line

The GUI is a shell over the module, so everything is scriptable. The one flag to remember is -OversharedOnly, which drops the noise and leaves you only the routes worth acting on:

Get-UserAccess -User jane@contoso.com `
    -SiteUrl "https://contoso.sharepoint.com/sites/Marketing" `
    -ClientId "<your-app-id>" -Interactive -OversharedOnly |
    Export-UserAccessReport -Path .\jane-access.html -Html

Swap -SiteUrl for -TenantWide -TenantAdminUrl "https://contoso-admin.sharepoint.com" to sweep everything, or add -Deep -IncludeItems to go down to the file level on a single site.

The by-site side has its own command, Get-SiteAccess:

Get-SiteAccess -SiteUrl "https://contoso.sharepoint.com/sites/Marketing" `
    -ClientId "<your-app-id>" -Interactive -Deep -OversharedOnly

Add -ExpandMembers and every group becomes one row per person, for the full list of who can actually reach the site.

It pairs well with the sharing-link work in my PnP PowerShell posts. If you run it and something looks off, open an issue on the repo.

Microsoft Graph PowerShell vs PnP PowerShell: Which One to Use

[AUTHOR: open on a specific moment you hit this. A task where you installed the wrong module, or watched someone run both with two sign-in prompts stacked up for a job that touched a single SharePoint list. One or two concrete sentences.]

Every comparison of these two modules lists the same feature differences and stops exactly where the useful part starts. Which one do you install, today, for the thing in front of you.

For SharePoint work, the honest answer is not the one the comparisons set up.

The short version

SharePoint work, meaning sites, lists, libraries, permissions and content: PnP PowerShell.

Everything else in the tenant, meaning users, groups, licences, Teams, Intune, conditional access: Microsoft Graph PowerShell.

They are built completely differently

Microsoft Graph PowerShell is autogenerated from the Graph API. Every endpoint Microsoft exposes becomes a cmdlet, automatically, which is why the full umbrella runs to over forty sub-modules. It reaches the entire tenant, and the cmdlet names carry the fingerprints of the machine that generated them rather than a person who sat down to name them.

PnP PowerShell is community-built, focused on SharePoint, and its names read like English. On my machine it exposes 775 commands, every one shaped Verb-PnPNoun:

Get-PnPList
Get-PnPListItem
Set-PnPListItem
Add-PnPFile
Get-PnPSiteCollectionAdmin

You can guess most of them without looking. Writing a script at speed, one you have not written before, that guessability does more real work than any feature table will ever capture.

The support difference nobody says out loud

Microsoft Graph PowerShell is a Microsoft product. Something breaks, you have a support path and a ticket to raise.

PnP PowerShell is community-maintained. Microsoft backs the PnP initiative, but the module itself carries no official support and no ticket queue. It is excellent and it is everywhere, and none of that helps you at 2am when a production job has stopped and your organisation’s rule is that automation runs on vendor-supported tooling. Somewhere with that rule, the support line decides everything before the feature comparison even starts.

The September 2024 change is the tell. Microsoft deleted the shared app registration PnP had always used, and overnight every PnP script needed its own app registration to sign in. That is a community timeline landing on production systems, and I wrote the whole episode up in Connect-PnPOnline: Specified Method Is Not Supported.

Where each one is genuinely better

PnP wins on SharePoint depth, and it is not close. Graph can read a list and write an item. The moment the job is a permission audit, a bulk field change, a site template, or version cleanup, PnP has a command for it and Graph has you hand-assembling raw API calls. Everything in my PnP PowerShell posts would be considerably more work in Graph.

Graph wins on everything that is not SharePoint. Users, groups, licence assignment, Teams, mailboxes, devices, sign-in logs, conditional access. PnP has crept into a little of this, it even ships Get-PnPEntraIDApp and sign-in report commands, but Graph is the native home and always will be, because that is the API it is generated from.

Both do authentication properly

Neither module makes you trade away an auth method to get it. Connect-PnPOnline covers the full range:

-Interactive        yes
-DeviceLogin        yes
-ClientId           yes
-ClientSecret       yes
-CertificatePath    yes
-Thumbprint         yes
-ManagedIdentity    yes
-AccessToken        yes

Interactive when you are at the keyboard, certificate or managed identity for the unattended jobs. Connect-MgGraph covers the same ground on the Graph side.

The part that changes the question

PnP can call Graph directly. It ships with two commands for exactly that:

Invoke-PnPGraphMethod
Get-PnPGraphAccessToken

From inside a PnP session that is already connected, you can hit any Graph endpoint that has no PnP command of its own:

Invoke-PnPGraphMethod -Url "v1.0/users?`$top=5" -Method Get

Most comparisons never mention this, and it quietly dissolves the whole versus. A job that is mostly SharePoint with a little Graph in it does not need two modules, two connections and two auth contexts held in your head at once. You connect with PnP, do the SharePoint work with PnP commands, and drop to Invoke-PnPGraphMethod for the one user lookup you actually need.

So, in practice

  • Mostly SharePoint: install PnP, and reach into Graph with Invoke-PnPGraphMethod when you need to.
  • Mostly users, groups, Teams, devices: install Graph. It is the native tool and it is supported.
  • Production automation under a vendor-support rule: that rule decides it. Graph.
  • Both all day: install both. They coexist without complaint, and each is best at its own half.

Treating this as a religious choice is the thing that irritates me about how it gets argued online. They are tools with different shapes. I reach for PnP first because I live in SharePoint, and Graph stays installed for the days I do not 🙂

Find and Revoke SharePoint Sharing Links with PowerShell

Sharing links are the quietest oversharing problem in SharePoint. Somebody clicks Share, picks “anyone in the organisation”, and eighteen months later that link is still live and nobody remembers it exists. It does not show up as a normal permission. It hides as a group with a name like SharingLinks.042f4d62-d446-4d80-83d1-fdb40ce5956d.Flexible.23ce70ce, which nobody reads.

Then you turn on Copilot, and Copilot is very good at finding files people can reach. Every one of those forgotten links is now one prompt away from the wrong person.

I could not find a free tool that lists these links across a tenant, rates how risky each one is, and lets you revoke the bad ones. So I wrote one. It is on the PowerShell Gallery:

Install-Module SharingLinkAudit

It is on the PowerShell Gallery if you want to see the version and what is in the package first, and the source is on GitHub, MIT licensed. This post is how to use it.

Before you start

It needs PnP.PowerShell and your own Entra ID app registration, the same one any PnP tool needs since the shared app was retired. If you have not set that up, I wrote it up separately in Connect-PnPOnline: Specified Method Is Not Supported. Once you have the client ID, you are ready.

Audit one site

Get-SharingLink -SiteUrl "https://contoso.sharepoint.com/sites/Sales" `
    -ClientId "your-app-id" -Interactive

A browser opens, you sign in, and you get one object per link. A risky one looks like this:

ItemName    : Monthly Report.txt
ItemPath    : /sites/Sales/Shared Documents/Monthly Report.txt
Scope       : Organization
Access      : Edit
Recipients  : Everyone in the organisation
Expiration  :
HasPassword : False
RiskLevel   : High
WebUrl      : https://contoso.sharepoint.com/:t:/s/Sales/IQBiTS8ERt...
LinkId      : d695e0ac-057f-49fb-8d01-eff6bb843a44

Read that in one line: anyone in the organisation can edit this file, through a link that never expires. That is why it scored High.

If a site is clean it tells you so, rather than printing nothing and leaving you wondering whether it worked:

No sharing links found across 1 site(s).

How the risk is scored

The RiskLevel is the whole point. It is what turns a long list into a short list of things to actually deal with.

  • Anonymous “anyone with the link” is the worst starting point. Organization “anyone in the company” is a real exposure but internal. Users, specific named people, is what sharing is supposed to be.
  • Edit is worse than View.
  • No expiry counts against the broad links, because those are the ones that linger.
  • An anonymous link with no password is a plain open door.

So a specific-person view link is Low, an org-wide edit link with no expiry is High, and an anonymous no-password edit link is Critical. You filter to what matters:

Get-SharingLink -SiteUrl $url -ClientId $id -Interactive -MinimumRisk High

Audit the whole tenant

This is the one people actually want. It needs SharePoint admin and the admin URL:

Get-SharingLink -TenantWide `
    -TenantAdminUrl "https://contoso-admin.sharepoint.com" `
    -ClientId "your-app-id" -Interactive -MinimumRisk High

It walks every site in the tenant. On a real tenant that is the slow mode, so it does not fight SharePoint’s throttling. If it gets a 429 it waits the amount SharePoint asks for and carries on rather than falling over.

The way it finds the links is the part worth understanding. It does not walk every file, which on a big library would be tens of thousands of calls. It reads the hidden SharingLinks groups, which tell it exactly which files have a link without touching the rest. A library of fifty thousand documents with two hundred shared items costs about two hundred lookups, not fifty thousand. That is what makes tenant-wide practical.

Turn it into a report

Nobody reads a thousand rows scrolling past. Send it to an HTML report instead:

Get-SharingLink -TenantWide -TenantAdminUrl $admin -ClientId $id -Interactive -MinimumRisk High |
    Export-SharingLinkReport -Path .\sharing.html -Html
Report generated: 14 link(s) written to C:\reports\sharing.html

That is a self-contained page, colour-coded by risk and sorted worst first with no external anything, that you can open in a browser and email to whoever asked “are we overshared”. There is a .csv option too if you want it in Excel.

[SCREENSHOT: the HTML report open in a browser, showing the colour-coded risk pills and the table of links. Take it from a run against the demo site, not a real tenant.]

Revoke the bad ones, carefully

Revoking a sharing link cannot be undone. The link is gone and whoever relied on it has to be re-shared. So always look before you leap:

Get-SharingLink -SiteUrl $url -ClientId $id -Interactive |
    Where-Object Scope -eq 'Anonymous' |
    Remove-SharingLink -WhatIf

-WhatIf shows you exactly what would be removed and deletes nothing. Read that list. Then, when you are sure, drop the -WhatIf:

Get-SharingLink -SiteUrl $url -ClientId $id -Interactive |
    Where-Object Scope -eq 'Anonymous' |
    Remove-SharingLink

Because it reads from the pipeline, you revoke exactly what you filtered, whether that is the anonymous ones, the expired ones, or everything on one site. I would not do a blanket “remove every link in the tenant”, because most of those links are shares people meant to create. Find the genuinely bad ones, agree them with the site owners, then remove that set.

Running it unattended

For a weekly report on a schedule, swap the browser sign-in for a certificate:

Get-SharingLink -TenantWide -TenantAdminUrl $admin `
    -ClientId $id -CertificatePath .\audit.pfx -Tenant contoso.onmicrosoft.com |
    Export-SharingLinkReport -Path .\weekly-sharing.csv

That version needs the app to have Sites.FullControl.All, which is a high permission. Grant it deliberately, to a dedicated app, not to the interactive one you use day to day.

That is the whole thing

Audit a site or the tenant, filter to what is risky, report it, revoke what you agree to. It found genuine org-wide edit links the first time I pointed it at a real tenant, which is rather the point. This stuff is already there, quietly, waiting for Copilot to surface it.

It is free and open source. If you find a bug or it misses something, the GitHub repo is the place. I would rather fix it than have you work around it 🙂

List Every List and Library in a SharePoint Site with PowerShell

You want a quick inventory of a SharePoint site: what lists and libraries are in it, how many items each holds, how big they are. It sounds like one command. It is, but the obvious version lies to you in two ways, and the way most people get sizes is the slow way.

Run against a real site on PnP.PowerShell 2.12.0.

Get-PnPList returns far more than you asked for

$all = Get-PnPList
$all.Count
Total lists returned : 19
Hidden               : 11
Visible              : 8

Nineteen, on a site where I created four things. The rest are the machinery SharePoint runs on: Solution Gallery, TaxonomyHiddenList, Theme Gallery, the user information list, and so on. Eleven of the nineteen are hidden.

So the first job is filtering. You want visible lists, and you want to keep it to actual lists and libraries rather than galleries and form-template stores. Two base templates cover that: 100 is a list, 101 is a document library.

Get-PnPList |
    Where-Object { -not $_.Hidden -and $_.BaseTemplate -in @(100, 101) } |
    Select-Object Title,
        @{n='Kind'; e={ if ($_.BaseTemplate -eq 101) { 'Library' } else { 'List' } }},
        ItemCount,
        @{n='LastChanged'; e={ $_.LastItemUserModifiedDate }} |
    Sort-Object Kind, Title
Title             Kind    ItemCount LastChanged
-----             ----    --------- -----------
Agent Knowledge   Library         4 22/07/2026
Project Documents Library        14 22/07/2026
Departments       List            4 18/07/2026
Projects          List           25 22/07/2026

LastItemUserModifiedDate is worth having in there. It is the last time a person changed something, which is how you spot the libraries nobody has touched in a year.

ItemCount is not the number of files

Look at Project Documents above, ItemCount 14. But there are not fourteen documents in it. I checked:

Project Documents ItemCount   : 14
Actual files                  : 8
Folders (also in ItemCount)   : 6

ItemCount counts folders as items. A library with eight files across six folders reports fourteen. If you are telling someone “this library has 14 documents”, you are wrong by however many folders it has.

For the real file count you have to ask, and filter by object type:

@(Get-PnPListItem -List 'Project Documents' -PageSize 500 |
    Where-Object { $_.FileSystemObjectType -eq 'File' }).Count

Same trap applies to a list with folders in it, which is rarer but does happen.

Size: the fast way and the slow way

This is where an inventory script quietly becomes an overnight job, so it is worth understanding the two approaches before you pick one.

The fast way: ask the site for its own number

Every site collection already knows how much storage it uses. One call gets it, but the call lives in the admin centre:

Connect-PnPOnline -Url "https://contoso-admin.sharepoint.com" -ClientId "..." -Interactive
$site = Get-PnPTenantSite -Identity "https://contoso.sharepoint.com/sites/Demo"
$site.StorageUsageCurrent
Fast: 1 MB in 805ms

One request, under a second, and it does not care whether the site has ten files or ten million. The catch is that it is a whole-site figure and it needs SharePoint admin. You cannot break it down per library this way, and you cannot run it as an ordinary site member.

The slow way: add up every file

If you need it per library, or you do not have admin, you sum the file sizes yourself:

$libraries = Get-PnPList | Where-Object { -not $_.Hidden -and $_.BaseTemplate -eq 101 }

foreach ($lib in $libraries) {
    $bytes = 0
    Get-PnPListItem -List $lib -PageSize 500 |
        Where-Object { $_.FileSystemObjectType -eq 'File' } |
        ForEach-Object { $bytes += [long]$_.FieldValues['File_x0020_Size'] }

    [pscustomobject]@{
        Library = $lib.Title
        SizeMB  = [math]::Round($bytes / 1MB, 2)
    }
}
Library           SizeMB
-------           ------
Agent Knowledge     0.07
Project Documents   0.07
...

Took : 3.7s for 5 libraries

Note the field name, File_x0020_Size, with the encoded space. That is the internal name, and it will not work as “File Size”. I wrote up why that happens in finding a list’s internal column names.

The gap is unbounded, not four times

On my tiny demo site the fast way took 805ms and the slow way 3.7 seconds, roughly four times slower. That ratio is not the point, because it does not hold.

The fast way is one call regardless of size. The slow way is one call per item. On five near-empty libraries that is nothing. On a library of fifty thousand documents it is fifty thousand round trips, and now you are measuring in minutes per library rather than seconds per site. The gap grows with every file you add.

So the rule is simple. If you have admin and you only need whole-site totals, use StorageUsageCurrent and be done in a second. Only drop to summing files when you genuinely need a per-library breakdown, and when you do, expect it to be slow and scope it to one site at a time.

The version I keep

Get-PnPList |
    Where-Object { -not $_.Hidden -and $_.BaseTemplate -in @(100, 101) } |
    Select-Object Title,
        @{n='Kind';  e={ if ($_.BaseTemplate -eq 101) { 'Library' } else { 'List' } }},
        @{n='Items'; e={ $_.ItemCount }},
        @{n='LastChanged'; e={ $_.LastItemUserModifiedDate }} |
    Sort-Object Kind, Title |
    Format-Table -AutoSize

Fast, honest about what it is showing, and no admin needed. It counts folders in the item total, so if that matters for what you are doing, add the FileSystemObjectType filter, and know that you are trading speed for accuracy 🙂