How To

How to Find All Azure AD Groups and Distribution Lists a User Belongs To using PowerShell?

How to Find All Azure AD Groups and Distribution Lists a User Belongs To using PowerShell?

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

  1. Microsoft Graph — transitive group membership. Get-MgUserTransitiveMemberOf follows nested group chains automatically, so indirect memberships aren’t missed.
  2. Microsoft Graph — joined Teams. Get-MgUserJoinedTeam lists every Team the user belongs to.
  3. 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.
  4. 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/else for 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-Transcript plus a top-level try/catch/finally with a Read-Host pause 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

ColumnDescription
GroupNameDisplay name of the group, Team, channel, or DL (channels shown as Team > Channel)
TypeSecurity Group, M365 Group, Microsoft Team (Joined), Channel (private/shared), Distribution List
SourceAzureAD, Teams, Teams Deep Scan, or ExchangeOnline
EmailMail 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.