Introduction
As an M365 administrator, one of the recurring tasks is auditing user membership and ownership across various group types in Exchange Online. The challenge? Microsoft 365 has three distinct group types, each managed by different cmdlets — and a one-size-fits-all script simply won't work.
In this post, I'll walk you through a PowerShell script that:
- Detects group type automatically (M365 Group, Distribution Group, or Mail-enabled Security Group)
- Checks whether a user is an Owner, Member, Both, or None
- Reads groups from a CSV file for bulk processing
- Writes results to a timestamped CSV dynamically (row-by-row, not at the end)
The Three Group Types in Exchange Online
Before diving into the script, it's important to understand what we're dealing with:
| Group Type | RecipientTypeDetails | Managed By Cmdlet |
|---|---|---|
| Microsoft 365 Group | GroupMailbox |
Get-UnifiedGroup |
| Distribution Group | MailUniversalDistributionGroup |
Get-DistributionGroup |
| Mail-enabled Security Group | MailUniversalSecurityGroup |
Get-DistributionGroup |
Each type stores ownership and membership differently, which is why a single cmdlet can't cover all scenarios.
Prerequisites
- Exchange Online Management module installed:
Install-Module -Name ExchangeOnlineManagement -Force -AllowClobber - Connected to Exchange Online:
Connect-ExchangeOnline -UserPrincipalName admin@contoso.com
Input: groups.csv
Create a simple CSV with one column — the group email addresses you want to audit:
GroupEmail
hr-team@contoso.com
all-staff@contoso.com
it-security@contoso.com
The Script
# ─────────────────────────────────────────────
# Config
# ─────────────────────────────────────────────
$CsvPath = "C:\Scripts\groups.csv"
$UserEmail = "john.doe@contoso.com"
# Dynamic timestamped output file
$timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
$OutputPath = "C:\Scripts\output_$timestamp.csv"
Connect-ExchangeOnline -UserPrincipalName admin@contoso.com
# ─────────────────────────────────────────────
# Clear output file if it exists from a prior run
# ─────────────────────────────────────────────
if (Test-Path $OutputPath) { Remove-Item $OutputPath }
# ─────────────────────────────────────────────
# Process each group
# ─────────────────────────────────────────────
$results = foreach ($row in (Import-Csv $CsvPath)) {
$GroupEmail = $row.GroupEmail
$isOwner = $isMember = $false
# Detect group type
$unifiedGroup = Get-UnifiedGroup -Identity $GroupEmail -ErrorAction SilentlyContinue
$distGroup = Get-DistributionGroup -Identity $GroupEmail -ErrorAction SilentlyContinue
$groupType = if ($unifiedGroup) { "M365Group" }
elseif ($distGroup.RecipientTypeDetails -eq "MailUniversalDistributionGroup") { "DistributionGroup" }
elseif ($distGroup.RecipientTypeDetails -eq "MailUniversalSecurityGroup") { "SecurityGroup" }
else { "Unknown" }
Write-Host "[$GroupEmail] Detected Type: $groupType" -ForegroundColor Cyan
# Check ownership & membership based on group type
if ($groupType -eq "M365Group") {
$isOwner = [bool](Get-UnifiedGroupLinks -Identity $GroupEmail -LinkType Owners -ResultSize Unlimited |
Where-Object { $_.PrimarySmtpAddress -eq $UserEmail })
$isMember = [bool](Get-UnifiedGroupLinks -Identity $GroupEmail -LinkType Members -ResultSize Unlimited |
Where-Object { $_.PrimarySmtpAddress -eq $UserEmail })
}
elseif ($groupType -in "DistributionGroup", "SecurityGroup") {
$isMember = [bool](Get-DistributionGroupMember -Identity $GroupEmail -ResultSize Unlimited |
Where-Object { $_.PrimarySmtpAddress -eq $UserEmail })
$isOwner = [bool]($distGroup.ManagedBy |
Where-Object { $_ -like "*$($UserEmail.Split('@')[0])*" })
}
# Determine role
$role = switch ($true) {
($isOwner -and $isMember) { "Owner & Member" }
($isOwner) { "Owner only" }
($isMember) { "Member only" }
default { "None" }
}
# Build result object
$record = [PSCustomObject]@{
GroupEmail = $GroupEmail
User = $UserEmail
GroupType = $groupType
IsOwner = $isOwner
IsMember = $isMember
Role = $role
}
# Write to CSV immediately (not at the end)
$record | Export-Csv $OutputPath -Append -NoTypeInformation
Write-Host " → Role: $role" -ForegroundColor Green
$record # Feed into $results for Format-Table
}
# ─────────────────────────────────────────────
# Console summary
# ─────────────────────────────────────────────
Write-Host "`n=== Summary ===" -ForegroundColor Yellow
$results | Format-Table -AutoSize
Write-Host "`nOutput saved to: $OutputPath" -ForegroundColor Green
Sample Output
GroupEmail User GroupType IsOwner IsMember Role
--------- ---- --------- ------- -------- ----
hr-team@contoso.com john.doe@contoso.com M365Group True True Owner & Member
all-staff@contoso.com john.doe@contoso.com DistributionGroup False True Member only
it-security@contoso.com john.doe@contoso.com SecurityGroup False False None
Conclusion
Exchange Online group auditing doesn't need to be painful. By detecting group type dynamically and merging the overlapping DG/SG logic, this script stays concise while covering all three group types reliably. The dynamic CSV write and timestamped output make it production-ready for bulk audits.