InternaciousWhat changed

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

How it used to work

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.

What's different now

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

  1. 1Sign in to the Entra admin centre and open Users. Filter the list to show guests only.
  2. 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.
  3. 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

<#
.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.

PlanLast sign-in columnAutomated access reviews
Business StandardNoNo
Business PremiumYesNo

Automated access reviews need Entra ID P2 or Entra ID Governance, which neither plan includes.

Related service

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