Recently, I have been tasked to check every group, Team, and distribution list that a user has access to. — and answered by clicking through the Microsoft 365 admin portal one item at a time, you know exactly how painful that gets in a tenant with hundreds of groups and dozens of Teams.
This comes up constantly during offboarding, access reviews, and security audits: before you disable or delete an account, you need the full picture of what it can reach. Manually checking is slow, and it’s easy to miss things especially nested group memberships, or private/shared Teams channels that don’t show up anywhere in the standard admin UI. Also if email forwarding is enabled for this user, then we will get an email form any specific dls are group that this user is part of, this script will allow you to Find All Azure AD Groups and Distribution Lists a User Belongs To using PowerShell.
I built a PowerShell script that scans a tenant end-to-end for a single user and reports:
- Azure AD Security Groups & Microsoft 365 Groups (including nested/transitive memberships)
- Microsoft Teams the user has joined
- Private and shared Teams channels — these are invisible from the regular Team membership view and need a separate deep scan
- Exchange Online Distribution Lists
Everything gets written to one CSV report. Below is the finished script, followed by every real error I hit building it — including a couple of genuinely nasty ones that I think are worth knowing about even if you never touch this exact script.
What the Script Checks
- Microsoft Graph — transitive group membership.
Get-MgUserTransitiveMemberOffollows nested group chains automatically, so indirect memberships aren’t missed. - Microsoft Graph — joined Teams.
Get-MgUserJoinedTeamlists every Team the user belongs to. - Microsoft Graph — private/shared channel deep scan. For each joined Team, the script checks every non-standard channel and confirms actual membership — standard channels are already covered by step 2, but private/shared channels have their own separate membership list.
- Exchange Online — Distribution Lists. Uses a server-side filter (
Get-Recipient -Filter "Members -eq '$dn'") rather than looping through every DL in the tenant, which is both faster and avoids identity-ambiguity errors entirely.
The Script
<#
Diagnostic version:
- Wraps everything in try/catch + Start-Transcript so ANY error (including
parse-time issues in called code) gets written to a log file instead of
the window just closing.
- Logs PowerShell version up front (ternary '?:' syntax requires PS 7+;
this version avoids it so it runs on Windows PowerShell 5.1 too).
- Ends with a pause so the console window stays open to show the result.
#>
$UserEmail = "user@domain.com"
$CsvPath = Join-Path -Path $PSScriptRoot -ChildPath "UserFullAccessReport.csv"
$LogPath = Join-Path -Path $PSScriptRoot -ChildPath "UserFullAccessReport_debug.log"
# Start a full transcript FIRST, before anything else can fail silently
Start-Transcript -Path $LogPath -Append | Out-Null
try {
Write-Host "PowerShell version: $($PSVersionTable.PSVersion)" -ForegroundColor Gray
Write-Host "Edition: $($PSVersionTable.PSEdition)" -ForegroundColor Gray
# 1. Connect to Microsoft Graph
Write-Host "Connecting to Microsoft Graph..." -ForegroundColor Cyan
Connect-MgGraph -Scopes @(
"User.Read.All",
"Group.Read.All",
"Directory.Read.All",
"TeamMember.Read.All",
"Team.ReadBasic.All",
"Channel.ReadBasic.All",
"ChannelMember.Read.All"
) -NoWelcome -ErrorAction Stop
$Results = [System.Collections.Generic.List[PSCustomObject]]::new()
# 2. Verify User
Write-Host "Verifying user..." -ForegroundColor Cyan
$user = Get-MgUser -UserId $UserEmail -Property "Id","DisplayName","AccountEnabled" -ErrorAction Stop
$status = if ($user.AccountEnabled) { "Enabled" } else { "Disabled/Offboarded" }
Write-Host "Target: $($user.DisplayName) ($status)" -ForegroundColor Yellow
# 3. Azure AD Groups & Teams (Transitive)
Write-Host "Scanning Azure AD Groups..." -ForegroundColor Cyan
try {
$memberships = Get-MgUserTransitiveMemberOf -UserId $user.Id -All -ErrorAction Stop
Write-Host " Found $($memberships.Count) transitive memberships." -ForegroundColor Gray
foreach ($m in $memberships) {
try {
$group = Get-MgGroup -GroupId $m.Id -Property "Id","DisplayName","Mail","GroupTypes","ResourceProvisioningOptions" -ErrorAction Stop
$type = "Security Group"
if ($group.GroupTypes -contains "Unified") {
# PS 5.1-compatible replacement for the ternary operator
if ($group.ResourceProvisioningOptions -contains "Team") {
$type = "Microsoft Team"
} else {
$type = "M365 Group"
}
}
$Results.Add([PSCustomObject]@{
GroupName = $group.DisplayName
Type = $type
Source = "AzureAD"
Email = $group.Mail
})
}
catch {
Write-Warning " Could not resolve group $($m.Id): $($_.Exception.Message)"
}
}
}
catch {
Write-Warning "Failed to fetch transitive memberships: $($_.Exception.Message)"
}
# 4. Teams Private/Shared Channels
Write-Host "Scanning for Private/Shared Channels..." -ForegroundColor Cyan
try {
$joinedTeams = Get-MgUserJoinedTeam -UserId $user.Id -ErrorAction Stop
Write-Host " User is a member of $($joinedTeams.Count) team(s)." -ForegroundColor Gray
foreach ($team in $joinedTeams) {
# Log the team itself first
$Results.Add([PSCustomObject]@{
GroupName = $team.DisplayName
Type = "Microsoft Team (Joined)"
Source = "Teams"
Email = "N/A"
})
try {
$channels = Get-MgTeamChannel -TeamId $team.Id -ErrorAction Stop
foreach ($channel in $channels) {
if ($channel.MembershipType -ne "standard") {
$members = Get-MgTeamChannelMember -TeamId $team.Id -ChannelId $channel.Id -All -ErrorAction Stop
$isMember = $members | Where-Object { $_.AdditionalProperties["userId"] -eq $user.Id }
if ($isMember) {
$Results.Add([PSCustomObject]@{
GroupName = "$($team.DisplayName) > $($channel.DisplayName)"
Type = "Channel ($($channel.MembershipType))"
Source = "Teams Deep Scan"
Email = "N/A"
})
}
}
}
}
catch {
Write-Warning " Could not scan channels for team $($team.DisplayName): $($_.Exception.Message)"
}
}
}
catch {
Write-Warning "Failed to fetch joined teams: $($_.Exception.Message)"
Write-Warning " (This usually means the 'Team.ReadBasic.All' scope wasn't consented in your tenant.)"
}
# 5. Connect to Exchange Online (Device Code)
Write-Host "`nConnecting to Exchange Online (Device Code)..." -ForegroundColor Cyan
Write-Host "If prompted, go to https://microsoft.com/devicelogin and enter the code shown." -ForegroundColor Magenta
Connect-ExchangeOnline -Device -ShowBanner:$false -ErrorAction Stop
# 6. Exchange Online Distribution Lists
Write-Host "Searching Distribution Lists..." -ForegroundColor Cyan
try {
$exoUser = Get-Recipient -Identity $UserEmail -ErrorAction Stop
$dn = $exoUser.DistinguishedName
$dls = Get-Recipient -Filter "Members -eq '$dn'" -ResultSize Unlimited -ErrorAction Stop
Write-Host " Found $($dls.Count) distribution list(s)." -ForegroundColor Gray
foreach ($dl in $dls) {
$Results.Add([PSCustomObject]@{
GroupName = $dl.DisplayName
Type = "Distribution List"
Source = "ExchangeOnline"
Email = $dl.PrimarySmtpAddress
})
}
}
catch {
Write-Warning "Exchange recipient lookup failed: $($_.Exception.Message)"
}
# Final Export
$finalData = $Results | Sort-Object Type, GroupName -Unique
$finalData | Export-Csv -Path $CsvPath -NoTypeInformation -Encoding UTF8BOM
Write-Host "`nSuccess! Found $($finalData.Count) memberships." -ForegroundColor Green
Write-Host "Report saved to: $CsvPath" -ForegroundColor Green
# NOTE: Disconnect-ExchangeOnline is intentionally skipped here.
# Its internal token-cleanup call (ClearAllTokensAsync) hits the same
# WAM broker bug seen during Connect-ExchangeOnline, but throws on a
# background thread - which crashes the whole process and cannot be
# caught by try/catch. The EXO session ends naturally when this
# PowerShell session/window closes, so skipping this is safe.
try { Disconnect-MgGraph | Out-Null } catch {}
}
catch {
# Catches ANYTHING that would otherwise silently close the window,
# including auth failures, missing modules, or unexpected exceptions.
Write-Host "`n=== SCRIPT FAILED ===" -ForegroundColor Red
Write-Host "Error message : $($_.Exception.Message)" -ForegroundColor Red
Write-Host "Failed at line : $($_.InvocationInfo.ScriptLineNumber)" -ForegroundColor Red
Write-Host "Command : $($_.InvocationInfo.Line.Trim())" -ForegroundColor Red
Write-Host "Full details have been written to: $LogPath" -ForegroundColor Yellow
}
finally {
Stop-Transcript | Out-Null
Write-Host "`nLog saved to: $LogPath"
Write-Host "Press Enter to close this window..."
Read-Host | Out-Null
}
Prerequisites
Install-Module Microsoft.Graph -Scope CurrentUser
Install-Module ExchangeOnlineManagement -Scope CurrentUser
You’ll need an account with, at minimum:
- Graph:
Group.Read.All,Directory.Read.All,TeamMember.Read.All,Team.ReadBasic.All,Channel.ReadBasic.All,ChannelMember.Read.All— all with admin consent granted, not just requested - Exchange Online: View-Only Recipients role or higher
Then set $UserEmail at the top and run the script from an actual PowerShell console (.\ScriptName.ps1) — not by double-clicking the file. More on why below.
Real Errors I Hit While Building This
This ended up being a genuinely useful debugging exercise, so I’m including all of it — if you’re scripting against Microsoft Graph and Exchange Online together, you’ll probably run into at least one of these eventually.
1. Connect-ExchangeOnline fails with a RuntimeBroker NullReferenceException
Error Acquiring Token:
System.NullReferenceException: Object reference not set to an instance of an object.
at Microsoft.Identity.Client.Platforms.Features.RuntimeBroker.RuntimeBroker..ctor...
This is a known bug in the WAM (Web Account Manager) authentication broker used by ExchangeOnlineManagement, especially inside embedded terminals (VS Code, PowerShell ISE) or on machines where the Windows account broker isn’t fully configured.
Fix: authenticate with device code instead of the broker:
powershell
Connect-ExchangeOnline -Device
You’ll get a URL and a short code to enter at https://microsoft.com/devicelogin in any browser — completely sidesteps the broken broker path.
2. The script closes immediately with no visible error
This one’s sneaky because there’s nothing to read — the window just flashes and disappears. It turned out to be a parse-time error: an early version of this script used the ternary operator (? :):
powershell
$type = ($group.ResourceProvisioningOptions -contains "Team") ? "Microsoft Team" : "M365 Group"
That syntax only exists in PowerShell 7+. On Windows PowerShell 5.1 (still the default powershell.exe on most Windows machines), this throws a parse error before a single line of the script executes — so nothing prints, and double-clicking the file closes the window instantly since there’s no console to hold it open.
Fixes:
- Replace ternary expressions with plain
if/elsefor compatibility with both versions. - Always run scripts from an already-open console (
.\script.ps1), not by double-clicking — that way even a hard failure leaves the window open. - For real diagnosability, wrap the whole script in
Start-Transcript/Stop-Transcriptplus a top-leveltry/catch/finallywith aRead-Hostpause at the end (see the script above) — this captures everything to a log file and keeps the window open regardless of what happens.
3. An unhandled exception crashes the process after the script already succeeded
This was the strangest one. The script would run completely, print Success! Found 93 memberships., write the CSV — and then the whole PowerShell process would crash anyway:
An error has occurred that was not properly handled. Additional information is shown below. The PowerShell process will exit.
Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object.
at Microsoft.Exchange.Management.AdminApiProvider.Authentication.MSALTokenProvider.ClearAllTokensAsync()
at System.Threading.Tasks.Task.<>c.<ThrowAsync>b__124_1(Object state)
at System.Threading.ThreadPoolWorkQueue.Dispatch()
Notice ThreadPoolWorkQueue.Dispatch() — this exception fires on a background thread, triggered by Disconnect-ExchangeOnline‘s internal token cleanup. In .NET, an unhandled exception on a background thread crashes the entire process by design, and no try/catch in your script can catch it, because the exception isn’t happening anywhere in your script’s call stack.
Fix: since all the real work (data collection, CSV export) already completes before this happens, the pragmatic fix is to simply skip Disconnect-ExchangeOnline entirely. The session ends naturally when the PowerShell window closes anyway.
4. Team names missing from the report entirely
The script correctly detected User is a member of 28 team(s) in its console output — but the CSV came back with zero Teams-related rows. The bug: the private/shared channel deep-scan loop only added a row to the results when it found a matching private/shared channel. It never logged the team itself. For a user whose Azure AD group scan came back empty (see below) and who wasn’t in any private/shared channels, nothing about their 28 Teams ever made it into the report.
Fix: log each joined team’s name as soon as it’s fetched from Get-MgUserJoinedTeam, independent of whether any private/shared channel matches are found later.
5. Diagnostic finding: 0 Azure AD groups but 28 joined Teams for a disabled user
Not a script bug, but worth calling out as a real finding: for a recently disabled/offboarded account, Get-MgUserTransitiveMemberOf returned zero results, while Get-MgUserJoinedTeam still showed 28 teams. Every Team is backed by an M365 Group, so these numbers should roughly track together.
The likely explanation: offboarding automation had already stripped the account from Azure AD group objects, but Microsoft Teams’ membership cache — a separate backend — hadn’t caught up yet. If you’re building an offboarding audit process, this is a good reminder to re-check a day or two after disabling an account, not just at the moment of disabling it, since some systems lag behind the directory state.
What the Output Looks Like
| Column | Description |
|---|---|
GroupName | Display name of the group, Team, channel, or DL (channels shown as Team > Channel) |
Type | Security Group, M365 Group, Microsoft Team (Joined), Channel (private/shared), Distribution List |
Source | AzureAD, Teams, Teams Deep Scan, or ExchangeOnline |
Email | Mail address where applicable, N/A for Teams/channels |
Combined with the transcript log, you get a full, timestamped audit trail — useful to attach directly to an offboarding ticket or access review.
Wrapping Up
The finished script is only about 150 lines, but nearly every one of the five issues above cost real debugging time to track down — particularly the background-thread crash, which is a genuinely non-obvious .NET behavior that no amount of try/catch in PowerShell can work around. If you’re building similar tenant-audit tooling against Microsoft Graph and Exchange Online, hopefully this saves you from hitting the same walls.