What changed · Guest access
People from closed matters may still have access to your Teams
Firms often add people from outside the firm, such as counsel or the client, to Teams as guests so they can work on a matter together. When the matter finishes, the guest account usually stays. For years that didn't matter much. It matters more now because clients and insurers are asking firms to say who can get to their material.
By Dale Harper · Last checked September 2026
A guest account was a convenient way to share a Teams channel with someone outside the firm. Once the matter ended nobody used it, so there was no pressing reason to remove it.
Clients and insurers ask more often who outside the firm can access client material, and AI has added to the questions. When a security questionnaire asks who can access client data, your guest list is part of the answer, so it needs to be short and current.
Check it yourself
- 1Sign in to the Entra admin centre and open Users. Filter the list to show guests only.
- 2If you're on Business Premium, add the last sign-in column and sort by it. Business Standard doesn't show this column, so skip this step.
- 3Mark each guest you can't link to a current matter.
Any names you've marked are the ones to look at first.
Run the check as a script
This PowerShell script does the same check across the whole tenant. It lists every guest, when each last signed in and which teams each one is in, then saves the list as a CSV file with the longest unused accounts at the top.
The team list is the slow part to put together by hand, because the admin centre shows memberships one guest at a time. The script only reads from your tenant and doesn't change anything.
You'll need
- —Windows PowerShell 5.1 or PowerShell 7.
- —The Microsoft Graph module. Install it with Install-Module Microsoft.Graph.Authentication -Scope CurrentUser.
- —An account with the Global Reader role or higher. The first run asks for consent to three read permissions, which needs an administrator.
- —Business Premium for the last sign-in dates. On Business Standard the script still runs and leaves those columns blank.
- —A new PowerShell window. If the Exchange Online, Teams or Az modules are already loaded, sign-in can fail. Starting PowerShell with pwsh -NoProfile avoids this.
<#
.SYNOPSIS
Lists every guest account in a Microsoft 365 tenant, when each last signed
in, and which Teams each one belongs to. Exports the result to CSV.
.DESCRIPTION
Read only. The script makes GET requests to Microsoft Graph and changes
nothing in the tenant.
Last sign-in dates need Entra ID P1, which Business Premium includes. On
Business Standard the script still runs and the sign-in columns are left
blank.
Published by Internacious: https://internacious.com/what-changed/teams-guest-access-review
.PARAMETER OutputPath
Where to write the CSV. Defaults to guest-access-<date>.csv in the current
folder.
.EXAMPLE
.\Get-GuestAccessReport.ps1
.EXAMPLE
.\Get-GuestAccessReport.ps1 -OutputPath C:\Temp\guests.csv
.NOTES
Requires the Microsoft.Graph.Authentication module:
Install-Module Microsoft.Graph.Authentication -Scope CurrentUser
Sign in with an account that holds the Global Reader role or higher. The
first run asks for consent to three read permissions (User.Read.All,
GroupMember.Read.All and AuditLog.Read.All), which needs an administrator
who can grant consent.
#>
[CmdletBinding()]
param(
[string]$OutputPath = (Join-Path (Get-Location) ("guest-access-{0}.csv" -f (Get-Date -Format "yyyy-MM-dd")))
)
$ErrorActionPreference = "Stop"
$scopes = @("User.Read.All", "GroupMember.Read.All", "AuditLog.Read.All")
try {
Connect-MgGraph -Scopes $scopes -NoWelcome
}
catch {
# Another module (Exchange Online, Teams, Az, PnP) has loaded a different
# version of the Microsoft sign-in library into this session.
if ($_.Exception.Message -match "Could not load (type|file or assembly)") {
throw "Sign-in failed because another PowerShell module loaded a different version of the Microsoft sign-in library first. Open a new window with 'pwsh -NoProfile' and run this script before anything else."
}
throw
}
# Follows @odata.nextLink until every page has been read.
function Get-AllPages([string]$Uri) {
$items = @()
while ($Uri) {
$response = Invoke-MgGraphRequest -Method GET -Uri $Uri
$items += $response.value
$Uri = $response.'@odata.nextLink'
}
return $items
}
$baseSelect = "id,displayName,mail,userPrincipalName,createdDateTime,externalUserState"
# Page size is capped at 120 when signInActivity is selected.
$signInAvailable = $true
try {
$guests = Get-AllPages ("https://graph.microsoft.com/v1.0/users?`$filter=userType eq 'Guest'&`$select=$baseSelect,signInActivity&`$top=100")
}
catch {
if ($_.Exception.Message -match "Premium|RequestFromNonPremiumTenant|Authorization_RequestDenied|403") {
Write-Warning "Last sign-in dates aren't available in this tenant (they need Entra ID P1). Continuing without them."
$signInAvailable = $false
$guests = Get-AllPages ("https://graph.microsoft.com/v1.0/users?`$filter=userType eq 'Guest'&`$select=$baseSelect&`$top=999")
}
else {
throw
}
}
if (-not $guests -or $guests.Count -eq 0) {
Write-Host "No guest accounts found."
return
}
# Every Teams-enabled group in the tenant, by id. Checking each guest's groups
# against this list is more reliable than reading team status from the
# membership results, which don't always include it.
$teamNames = @{}
foreach ($team in (Get-AllPages "https://graph.microsoft.com/v1.0/groups?`$filter=resourceProvisioningOptions/Any(x:x eq 'Team')&`$select=id,displayName&`$top=999")) {
$teamNames[$team.id] = $team.displayName
}
$now = Get-Date
$rows = @()
$i = 0
foreach ($guest in $guests) {
$i++
Write-Progress -Activity "Checking guest memberships" -Status $guest.displayName -PercentComplete (($i / $guests.Count) * 100)
$memberships = Get-AllPages ("https://graph.microsoft.com/v1.0/users/{0}/memberOf?`$select=id" -f $guest.id)
$groups = @($memberships | Where-Object { $_.'@odata.type' -eq "#microsoft.graph.group" })
$teams = @($groups | Where-Object { $teamNames.ContainsKey($_.id) } | ForEach-Object { $teamNames[$_.id] } | Sort-Object)
# Latest of interactive and non-interactive sign-in. Blank means no sign-in
# has been recorded, which Entra has tracked since April 2020.
$lastSignIn = $null
if ($signInAvailable -and $guest.signInActivity) {
$dates = @($guest.signInActivity.lastSignInDateTime, $guest.signInActivity.lastNonInteractiveSignInDateTime) |
Where-Object { $_ } | ForEach-Object { [datetime]$_ }
if ($dates.Count -gt 0) {
$lastSignIn = ($dates | Sort-Object -Descending)[0]
}
}
$created = [datetime]$guest.createdDateTime
$rows += [pscustomobject]@{
DisplayName = $guest.displayName
Email = if ($guest.mail) { $guest.mail } else { $guest.userPrincipalName }
InvitationState = $guest.externalUserState
Created = $created.ToString("yyyy-MM-dd")
DaysSinceCreated = [int]($now - $created).TotalDays
LastSignIn = if ($lastSignIn) { $lastSignIn.ToString("yyyy-MM-dd") } elseif ($signInAvailable) { "None recorded" } else { $null }
DaysSinceLastSignIn = if ($lastSignIn) { [int]($now - $lastSignIn).TotalDays } else { $null }
TeamCount = $teams.Count
Teams = $teams -join "; "
OtherGroupCount = $groups.Count - $teams.Count
}
}
Write-Progress -Activity "Checking guest memberships" -Completed
# Oldest activity first: guests with no recorded sign-in, then the longest idle.
$sorted = $rows | Sort-Object @{ Expression = { if ($null -eq $_.DaysSinceLastSignIn) { [int]::MaxValue } else { $_.DaysSinceLastSignIn } }; Descending = $true }, DaysSinceCreated -Descending
$sorted | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8
$pending = @($rows | Where-Object { $_.InvitationState -eq "PendingAcceptance" }).Count
$inTeams = @($rows | Where-Object { $_.TeamCount -gt 0 }).Count
Write-Host ""
Write-Host ("{0,-34}{1}" -f "Teams in the tenant:", $teamNames.Count)
Write-Host ("{0,-34}{1}" -f "Guests:", $rows.Count)
Write-Host ("{0,-34}{1}" -f "Invitation never accepted:", $pending)
Write-Host ("{0,-34}{1}" -f "Member of at least one team:", $inTeams)
if ($signInAvailable) {
$never = @($rows | Where-Object { $_.LastSignIn -eq "None recorded" }).Count
$idle = @($rows | Where-Object { $null -ne $_.DaysSinceLastSignIn -and $_.DaysSinceLastSignIn -ge 180 }).Count
Write-Host ("{0,-34}{1}" -f "No sign-in recorded:", $never)
Write-Host ("{0,-34}{1}" -f "No sign-in for 180 days or more:", $idle)
}
Write-Host ""
Write-Host "Saved to $OutputPath"
Save it, open PowerShell in the same folder and run .\Get-GuestAccessReport.ps1
What to do about it
You can do most of this yourself. Check with the partner responsible before removing anyone, then remove guests who were only there for matters that have closed.
For guests who are staying, check which teams they're in. Guests are added to a whole team, so they can usually see every standard channel in it, not only the one they were invited for.
Microsoft has a feature that runs these reviews automatically, but it needs a higher licence than most small firms have. A manual check against your list of closed matters every three months covers the same ground for a firm your size, and gives you a record of when it was done.
| Plan | Last sign-in column | Automated access reviews |
|---|---|---|
| Business Standard | No | No |
| Business Premium | Yes | No |
Automated access reviews need Entra ID P2 or Entra ID Governance, which neither plan includes.
Copilot Exposure Assessment
Guest access is one part of a wider question about who can see what in your Microsoft 365 tenant. The assessment reviews guest accounts along with sharing inside the firm, which matters more once Copilot is switched on.
About the assessment