This is the one piece of work in the collaboration baseline with a date somebody else chose. During October, SharePoint’s own passcode authentication for external people retires, and anyone holding a link without a guest account in your directory stops being able to open it. This build sheet finds those people, creates the accounts, and proves the gap is closed. The work is an inventory and a join, it is not difficult, and it takes longer than you think because the interesting part is reconciling addresses rather than running cmdlets.
The estate is catsnackjack.com, tenant catsnackjack.onmicrosoft.com, with a document estate of about 400 sites, a client-facing project site that has been sharing externally since 2021, and a finance team that sends month-end packs to an accountant nobody has ever added to anything. That last one is the whole problem in one sentence.
One convention I use and recommend: put every guest account you create during this exercise into a tracking group, SG-EXT-B2B-Backfill, so that in six months you can answer what you did, review the population, and remove people whose engagement ended. A backfill that leaves no trace becomes 300 unexplained guests.
Before you start
You need SharePoint administrator to read the sharing data and an Entra role that can create guest accounts, which means Guest Inviter at minimum and in practice User Administrator, because you will also want to set properties and group membership. Confirm the tenant before any write. This exercise creates directory objects and doing it in the wrong tenant is embarrassing in a way that is hard to undo quietly.
$ErrorActionPreference = 'Stop'
Install-Module Microsoft.Online.SharePoint.PowerShell -Scope CurrentUser
Install-Module Microsoft.Graph -Scope CurrentUser
Connect-SPOService -Url https://catsnackjack-admin.sharepoint.com
Connect-MgGraph -Scopes 'User.ReadWrite.All','User.Invite.All','GroupMember.ReadWrite.All','Directory.Read.All'
# Assert you are where you think you are, before anything is created
Get-MgContext | Format-List TenantId,Account,Scopes
Get-SPOTenant | Format-List SharingCapability
Step 1: size the problem before you decide how to solve it
How you run this depends entirely on scale, and the count takes two minutes. An estate with 40 external people gets handled by a person reading a list. An estate with 4,000 needs the join automated and the exceptions triaged. Find out which one you have first.
# Every external person SharePoint knows about, paged
$external = @()
$pos = 0
do {
$batch = Get-SPOExternalUser -PageSize 50 -Position $pos
$external += $batch
$pos += 50
} while ($batch.Count -eq 50)
"SharePoint knows about {0} external people." -f $external.Count
# Existing guest accounts in the directory
$guests = Get-MgUser -All -Filter "userType eq 'Guest'" -Property Id,Mail,UserPrincipalName,DisplayName,CreatedDateTime
"The directory holds {0} guest accounts." -f $guests.Count
If the two numbers are close, you are probably in good shape and this is a verification exercise. If SharePoint knows about far more people than the directory holds, that difference is your work.
Step 2: get the per-site picture, because the tenant list is not enough
The tenant-wide external user list tells you who exists. It does not tell you what they were sent, and you will need that for the exceptions, because the only way to judge whether an unresolvable address matters is to know what it can reach.
Run the external sharing report per site from the SharePoint admin centre, under the site’s sharing settings, which produces a file listing every externally shared item with the address it was shared to. The column you care about is the recipient email. For a large estate, run it against the sites that actually share externally rather than all of them.
# The sites worth reporting on: anything not fully disabled for external sharing
Get-SPOSite -Limit All |
Where-Object { $_.SharingCapability -ne 'Disabled' } |
Select-Object Url,SharingCapability,LastContentModifiedDate |
Sort-Object LastContentModifiedDate -Descending |
Export-Csv .\sites-sharing-externally.csv -NoTypeInformation
Keep those reports. They are the evidence that the exercise was done and the reference for anything that later turns out to have been missed.
Step 3: compute the gap, and understand why it is an address problem
This is the whole job. Join the external people SharePoint knows about against the guest accounts in the directory, and the ones with no match are your list.
The reason it is harder than a join looks is that a guest account can hold more than one address. The account’s mail attribute, its user principal name and the address a link was originally sent to can all differ, most commonly when someone’s organisation moved domains or when a link went to an alias. Matching on one attribute alone produces false gaps, and a false gap means you create a duplicate-looking account for someone who is already fine. Match on all of them.
# Build a lookup of every address any guest account answers to
$known = New-Object System.Collections.Generic.HashSet[string] ([StringComparer]::OrdinalIgnoreCase)
foreach ($g in $guests) {
if ($g.Mail) { [void]$known.Add($g.Mail) }
if ($g.UserPrincipalName) { [void]$known.Add($g.UserPrincipalName) }
# A B2B guest UPN encodes the source address before #EXT#
if ($g.UserPrincipalName -match '^(.+?)#EXT#') {
[void]$known.Add(($matches[1] -replace '_','@'))
}
}
$gap = $external | Where-Object { -not $known.Contains($_.Email) }
"{0} external people have no guest account." -f $gap.Count
$gap | Select-Object DisplayName,Email,LoginName |
Export-Csv .\guest-gap.csv -NoTypeInformation
Read the resulting file before acting on it. Every estate I have run this against had three recognisable groups in there: real external collaborators who genuinely need accounts, people who left their organisations years ago, and distribution addresses that were never a person at all. Only the first group needs an account. The second group should be allowed to lose access, which is the quiet benefit of this whole exercise. The third needs a decision about whether the sharing was appropriate in the first place.
Step 4: decide the invitation posture before you send anything
Creating a guest account can send the person an invitation email, or it can create the account silently. This is a decision, not a default, and getting it wrong produces either a support incident or a compliance question.
| Setting | Value | Why |
|---|---|---|
| Invitation message | Suppressed for the backfill | These people already have your files. An unexpected invitation from a firm they dealt with two years ago reads as phishing and generates calls. |
| Display name | Their name where the report has it, otherwise the address | An estate of guests named after email addresses is unusable in a later review |
| Tracking group | SG-EXT-B2B-Backfill | Makes the population reviewable and reversible; without it this work is invisible in six months |
| Redirect URL | Your own portal or the tenant root | Only used if someone follows the invitation; pointing it somewhere sensible costs nothing |
Suppressing the message is the right call here specifically because this is a backfill for existing access. For new external collaboration, send the invitation.
Step 5: create the accounts
Do a batch of five first and verify them before running the rest. Creating 300 directory objects on an unverified pattern is how a tidy exercise becomes a cleanup exercise.
$group = Get-MgGroup -Filter "displayName eq 'SG-EXT-B2B-Backfill'"
if (-not $group) {
$group = New-MgGroup -DisplayName 'SG-EXT-B2B-Backfill' -MailEnabled:$false `
-SecurityEnabled -MailNickname 'sg-ext-b2b-backfill' `
-Description 'Guest accounts created for the SPO OTP retirement backfill'
}
$targets = Import-Csv .\guest-gap-approved.csv # the reviewed file, not the raw gap
$created = @()
foreach ($t in $targets) {
$name = if ($t.DisplayName) { $t.DisplayName } else { $t.Email }
$inv = New-MgInvitation -InvitedUserEmailAddress $t.Email `
-InvitedUserDisplayName $name `
-InviteRedirectUrl 'https://catsnackjack.sharepoint.com' `
-SendInvitationMessage:$false
New-MgGroupMember -GroupId $group.Id -DirectoryObjectId $inv.InvitedUser.Id
$created += [pscustomobject]@{ Email = $t.Email; ObjectId = $inv.InvitedUser.Id }
}
$created | Export-Csv .\guests-created.csv -NoTypeInformation
"Created {0} guest accounts." -f $created.Count
Keep guests-created.csv. It is your rollback list and your evidence.
Step 6: re-run the gap and confirm it is empty
Directory replication is not instant, so leave it a few minutes, then run steps 1 and 3 again from a fresh session. The gap should contain only the people you deliberately excluded. If it contains anyone you thought you created, the address you invited is not the address SharePoint holds, which is the failure mode step 3 warned about.
# Fresh pull, then the same join
$guests2 = Get-MgUser -All -Filter "userType eq 'Guest'" -Property Id,Mail,UserPrincipalName
$stillMissing = $external | Where-Object {
$e = $_.Email
-not ($guests2 | Where-Object { $_.Mail -eq $e -or $_.UserPrincipalName -like "*$($e -replace '@','_')*" })
}
"{0} still unmatched." -f $stillMissing.Count
$stillMissing | Format-Table DisplayName,Email
Step 7: the end-to-end test
A count of zero proves the join. It does not prove access works, and those are different claims. Test it with a real person and a real link.
Pick one external collaborator from the created list who you can actually contact, ideally someone at a partner firm you speak to. Ask them to open a link you sent them before this exercise, not a new one. The result you want is that the file opens after they sign in with their own organisational or Microsoft account, with no passcode prompt. That confirms the account exists, matches the address the link was issued to, and is being used for authentication.
Then run the half people skip. Pick one address you deliberately left out of the backfill, someone who left their organisation, and confirm the account genuinely does not exist in the directory rather than existing under an alias you missed. That is the negative test, and it is the only way to know your exclusions were decisions rather than oversights.
One caution when interpreting a failure before the retirement lands. Until the cutoff, the old passcode path still works, so a link opening successfully does not prove the guest account is doing the work. If the person is prompted for a code, the account is not being used and something is wrong regardless of what your join says.
Step 8: the sharing failures you may hit afterwards
Some tenants that were force-enabled onto the integration hit sharing failures caused by a stale cross-tenant policy rather than by anything in this exercise. If new external sharing starts failing and the guest accounts look correct, refresh the management policy and review your cross-tenant access settings before assuming the backfill broke something.
Set-SPOTenant -SyncAadB2BManagementPolicy $true
Get-SPOTenant | Format-List SyncAadB2BManagementPolicy
I have seen this reported rather than reproduced it myself, so treat it as a troubleshooting step rather than part of the build.
Rollback
Rollback is deleting the accounts you created, from guests-created.csv. Understand what it costs before you run it: deleting a guest account removes that person’s access to everything, which after the cutoff is permanent rather than reversible by resharing. Deleted directory objects are recoverable for 30 days, so a mistake caught quickly is fixable and a mistake caught in November is not.
# Destructive. Confirm the file is the one you think it is first.
$rollback = Import-Csv .\guests-created.csv
$rollback | ForEach-Object { Remove-MgUser -UserId $_.ObjectId -Confirm:$false }
"Removed {0} accounts. Recoverable for 30 days." -f $rollback.Count
In practice the rollback you actually want is narrower: remove the handful you should not have created, not the batch. Which is another reason the tracking group and the created list matter.
Completion checklist
- The external population and the guest population are both counted, and the difference is understood rather than assumed
- Per-site sharing reports are exported and kept for the sites that share externally
- The gap was computed matching on mail, user principal name and the address encoded in a business-to-business guest principal, not on one attribute
- The raw gap was reviewed by a human and split into accounts to create, people to let lapse, and addresses that were never a person
- Invitation messages were suppressed deliberately, and the reason is written down
- A batch of five was verified before the rest ran
- Every created account is in
SG-EXT-B2B-Backfilland listed inguests-created.csv - The gap re-run from a fresh session returns only the deliberate exclusions
- A real external person opened a pre-existing link and was not prompted for a passcode
- One deliberate exclusion was confirmed genuinely absent from the directory rather than present under an alias
- The work completed with time before the cutoff, not during it
Best Practices
‹ Previous: [BP 3.1] The Collaboration Baseline: The Critical Tier
Next: [BP 3.2] The Collaboration Baseline: Recommended and Optional Tiers ›




