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?

This is one of the tasks that was assigned to me to check every group and distribution list this user is part of during the Offboarding. To resolve this, we can check every group and DL, one by one and check if that user is there in the DL or the Group, but its very time-consuming task.

This comes up constantly during offboarding, access reviews, and security audits: before you disable or delete an account, you need a full picture of what it has access to. Manually checking is slow and error-prone, especially when memberships are nested (a user in Group A, which is itself a member of Group B).

I put together a PowerShell script that does help you to find Azure AD groups user belongs to PowerShell: it scans the tenant, finds every group and distribution list (DL) a given user belongs to — including nested/transitive memberships — and exports everything to a clean CSV report. Here’s how it works, and the real-world errors I hit (and fixed) while building it.

What the Script Does

The script pulls membership data from two sources, because in most Microsoft 365 tenants, group/DL data doesn’t live in just one place:

  1. Microsoft Graph — returns transitive group membership: Security Groups, Microsoft 365 Groups, and mail-enabled Distribution Groups that are represented as Azure AD group objects. “Transitive” is the key word — it follows nested group chains automatically, so you don’t miss indirect memberships.
  2. Exchange Online — cross-checks classic Distribution Lists. Some tenants (especially ones with a hybrid or legacy Exchange history) have DLs that don’t cleanly surface through Graph, so this step closes that gap.

Everything gets merged, deduplicated, and written to a CSV — plus a plain-text log of the whole run, so you have an audit trail of exactly what was checked and when.

The Script

<#
.SYNOPSIS
    Checks which Groups / Distribution Lists a given user is a member of
    (across the entire tenant, including nested/transitive memberships),
    and exports the results to a CSV file.

.DESCRIPTION
    - Uses Microsoft Graph to pull transitive group membership (Security Groups,
      Microsoft 365 Groups, and mail-enabled Distribution Groups synced to Azure AD).
    - Uses Exchange Online to additionally verify membership in classic
      Distribution Lists (covers DLs that may not surface cleanly via Graph).
    - Combines both results and writes them to a hardcoded CSV path in the
      same folder as this script.

.REQUIREMENTS
    Install-Module Microsoft.Graph -Scope CurrentUser
    Install-Module ExchangeOnlineManagement -Scope CurrentUser

.NOTES
    Update the $UserEmail and permission scopes as needed before running.
#>

# ============================
# CONFIGURATION - EDIT THESE
# ============================
$UserEmail = "user@domain.com"                     # <-- Set the target user's email/UPN here

# Hardcoded CSV output path (saved in the same folder as this script)
$CsvPath = Join-Path -Path $PSScriptRoot -ChildPath "UserGroupDL_MembershipReport.csv"

# Log file (optional, plain text log of what happened during the run)
$LogPath = Join-Path -Path $PSScriptRoot -ChildPath "UserGroupDL_MembershipReport.log"

# ============================
# LOGGING HELPER
# ============================
function Write-Log {
    param([string]$Message)
    $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
    $line = "[$timestamp] $Message"
    Write-Host $line
    Add-Content -Path $LogPath -Value $line
}

Write-Log "=== Starting membership scan for user: $UserEmail ==="

$results = New-Object System.Collections.Generic.List[Object]

# ============================
# STEP 1: Microsoft Graph - Transitive Group/DL Membership
# ============================
try {
    Write-Log "Connecting to Microsoft Graph..."
    Connect-MgGraph -Scopes "User.Read.All", "Group.Read.All", "Directory.Read.All" -NoWelcome

    Write-Log "Verifying user exists in Azure AD..."
    $user = Get-MgUser -UserId $UserEmail -ErrorAction Stop
    Write-Log "User found: $($user.DisplayName) ($($user.Id))"

    Write-Log "Fetching transitive group/DL memberships from Microsoft Graph..."
    $graphMemberships = Get-MgUserTransitiveMemberOf -UserId $user.Id -All

    foreach ($m in $graphMemberships) {
        $type = $m.AdditionalProperties["@odata.type"]
        $mailEnabled = $m.AdditionalProperties["mailEnabled"]
        $securityEnabled = $m.AdditionalProperties["securityEnabled"]

        $groupType = switch ($true) {
            { $type -eq "#microsoft.graph.group" -and $mailEnabled -eq $true -and $securityEnabled -eq $false } { "Distribution List (Mail-Enabled)"; break }
            { $type -eq "#microsoft.graph.group" -and $securityEnabled -eq $true -and $mailEnabled -eq $true }  { "Mail-Enabled Security Group"; break }
            { $type -eq "#microsoft.graph.group" -and $securityEnabled -eq $true }                              { "Security Group"; break }
            { $type -eq "#microsoft.graph.group" }                                                              { "Microsoft 365 Group"; break }
            default { $type }
        }

        $results.Add([PSCustomObject]@{
            UserEmail   = $UserEmail
            Source      = "Microsoft Graph"
            GroupName   = $m.AdditionalProperties["displayName"]
            GroupType   = $groupType
            GroupId     = $m.Id
            MailAddress = $m.AdditionalProperties["mail"]
        })
    }
    Write-Log "Microsoft Graph returned $($graphMemberships.Count) group/DL memberships."
}
catch {
    Write-Log "ERROR during Microsoft Graph lookup: $($_.Exception.Message)"
}

# ============================
# STEP 2: Exchange Online - Classic Distribution Lists
# ============================
try {
    Write-Log "Connecting to Exchange Online..."
    try {
        Connect-ExchangeOnline -ShowBanner:$false -ErrorAction Stop
    }
    catch {
        Write-Log "Standard (WAM broker) sign-in failed: $($_.Exception.Message)"
        Write-Log "Retrying Exchange Online connection using device-code authentication..."
        Connect-ExchangeOnline -Device -ShowBanner:$false -ErrorAction Stop
    }

    Write-Log "Fetching all Distribution Groups in the tenant (this may take a while)..."
    $allDLs = Get-DistributionGroup -ResultSize Unlimited -WarningAction SilentlyContinue

    $dlCount = 0
    $skippedDLs = New-Object System.Collections.Generic.List[string]

    foreach ($dl in $allDLs) {
        try {
            $memberError = $null
            $isMember = Get-DistributionGroupMember -Identity $dl.Guid.ToString() -ResultSize Unlimited `
                -WarningAction SilentlyContinue -ErrorAction SilentlyContinue -ErrorVariable memberError |
                Where-Object { $_.PrimarySmtpAddress -eq $UserEmail }

            if ($memberError) {
                throw $memberError[0]
            }

            if ($isMember) {
                $dlCount++
                $results.Add([PSCustomObject]@{
                    UserEmail   = $UserEmail
                    Source      = "Exchange Online"
                    GroupName   = $dl.DisplayName
                    GroupType   = "Distribution List"
                    GroupId     = $dl.Guid
                    MailAddress = $dl.PrimarySmtpAddress
                })
            }
        }
        catch {
            # Skip corrupted/ambiguous/inconsistent DL objects rather than aborting the whole scan
            $skippedDLs.Add($dl.DisplayName)
        }
    }

    Write-Log "Exchange Online scan found $dlCount matching Distribution List(s)."
    if ($skippedDLs.Count -gt 0) {
        Write-Log "Skipped $($skippedDLs.Count) DL(s) due to errors reading membership: $($skippedDLs -join ', ')"
    }
}
catch {
    Write-Log "ERROR during Exchange Online lookup: $($_.Exception.Message)"
}

# ============================
# STEP 3: Export to CSV (deduplicated)
# ============================
$finalResults = $results | Sort-Object GroupName, Source -Unique

if ($finalResults.Count -eq 0) {
    Write-Log "No group or DL memberships found for $UserEmail."
}

$finalResults | Export-Csv -Path $CsvPath -NoTypeInformation -Encoding UTF8
Write-Log "Results exported to: $CsvPath"
Write-Log "=== Scan complete. Total unique memberships: $($finalResults.Count) ==="

# ============================
# STEP 4: Disconnect sessions
# ============================
try { Disconnect-MgGraph | Out-Null } catch {}
try { Disconnect-ExchangeOnline -Confirm:$false | Out-Null } catch {}

Write-Host "`nDone. CSV saved at: $CsvPath"
Write-Host "Log saved at: $LogPath"

Prerequisites

Before running it, install the two required modules (one-time setup):

powershell

Install-Module Microsoft.Graph -Scope CurrentUser
Install-Module ExchangeOnlineManagement -Scope CurrentUser

You’ll need an account with, at minimum:

  • Graph: Group.Read.All and Directory.Read.All (with admin consent granted in your tenant — not just requested)
  • Exchange Online: View-Only Recipients role or higher

Then just set $UserEmail at the top of the script to the account you’re investigating and run it.

Real Errors I Hit While Building This is How I Fix Them

This is the part most “here’s a script” articles skip — but if you actually run this against a production tenant, you’re likely to hit at least one of these. All three are addressed in the version above.

1. Exchange Online connection 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 newer versions of the ExchangeOnlineManagement module — it tends to show up in embedded terminals (VS Code, PowerShell ISE) or on machines where the Windows account broker isn’t fully configured.

Fix: update the module first (Update-Module ExchangeOnlineManagement -Force), and if it still fails, fall back to device-code authentication instead of the broker:

powershell

Connect-ExchangeOnline -Device

The script now tries the normal sign-in first and automatically retries with -Device if that fails.

2. Warnings about “corrupted” or inconsistent DL objects

WARNING: The object [DL Name] has been corrupted or isn't compatible with 
Microsoft support requirements, and it's in an inconsistent state.

This shows up when a distribution list has some metadata inconsistency in Exchange Online (e.g., no owner assigned, but membership approval is required). It’s just a warning — the cmdlet keeps going — but it clutters the log. The fix is to suppress it with -WarningAction SilentlyContinue and wrap each group’s membership check in its own try/catch so one bad object can’t derail the whole scan.

3. matches multiple entries errors

Get-DistributionGroupMember: The operation couldn't be performed because 
object: 'DevOps' matches multiple entries.

This happens when you query a group by its display name and multiple objects in the tenant share that name (a DL, a mail contact, and a mailbox all called “DevOps,” for example) — Exchange can’t tell which one you mean.

Fix: query by the group’s GUID instead of its name/Identity. GUIDs are always unique, so there’s no ambiguity:

powershell

Get-DistributionGroupMember -Identity $dl.Guid.ToString()

This one bit me hardest because the error is a non-terminating error from Exchange Online’s remoting layer — it printed straight to the console even inside a try/catch with -ErrorAction Stop. The reliable fix was to capture it explicitly with -ErrorVariable instead of relying on -ErrorAction alone.

What the Output Looks Like

The CSV report includes one row per membership, with:

ColumnDescription
UserEmailThe account that was scanned
SourceWhether it came from Microsoft Graph or Exchange Online
GroupNameDisplay name of the group/DL
GroupTypeSecurity Group, M365 Group, Distribution List, etc.
GroupIdUnique object ID/GUID
MailAddressThe group’s mail address, if applicable

Combined with the log file, you get both the “what” (the membership list) and the “how” (a timestamped record of the scan itself) — useful if this report needs to be attached to an offboarding ticket or an access review.

Wrapping Up

If you manage a Microsoft 365 tenant of any real size, having a script like this on hand turns a “let me manually check the portal” task into a two-minute automated report. The three fixes above — device-code fallback, warning suppression, and GUID-based lookups — are the difference between a script that works in a demo tenant and one that survives contact with a real, messy production environment.

Feel free to adapt the paths, add filtering for specific group types, or extend it to loop through a list of users for bulk offboarding audits.