maik.ing | the terminal garden
May 5th, 2026

Bulk User Assignment to Exchange Online Groups via PowerShell

When a batch of new team members needs access to multiple Microsoft 365 or Distribution Groups, the admin portal becomes the wrong tool immediately. Here is how to automate it cleanly — with type detection, error handling, and a CSV audit trail.


The Scenario

You have a list of users and a list of group Object IDs. Every user needs to be a member of every group. Doing this manually through the Microsoft 365 Admin Center means navigating to each group, opening the members panel, searching for each user, and confirming — multiplied by the number of users times the number of groups.

For 15 users and 3 groups, that is 45 manual operations. Automate it instead.


The Challenge: Two Group Types, Two Cmdlets

Exchange Online uses completely different cmdlets depending on the group type:

Group Type

Add Member Cmdlet

Microsoft 365 Group

Add-UnifiedGroupLinks

Distribution Group / Mail-Enabled Security Group

Add-DistributionGroupMember

Passing a GUID to the wrong cmdlet fails silently or throws an unhelpful error. The script handles this automatically by probing the group type first and branching accordingly.

Use GUIDs as identifiers, not display names. Display names are user-editable and can cause silent mismatches. Object IDs are immutable.


The Script

==PowerShell==

# ============================================================
# Add-UsersToGroups.ps1
# Adds a list of users to Exchange Online groups.
# Supports Microsoft 365 Groups and Distribution Groups.
# ============================================================ # Connect to Exchange Online
Write-Host "Connecting to Exchange Online..." -ForegroundColor Cyan
Connect-ExchangeOnline # ── Users ────────────────────────────────────────────────────
$Users = @(
"user1@yourdomain.com"
"user2@yourdomain.com"
"user3@yourdomain.com"
# Add remaining users here
) # ── Groups (Object IDs) ──────────────────────────────────────
$Groups = @(
"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
"yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy"
"zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz"
) # ── Processing ───────────────────────────────────────────────
$Results = @() foreach ($GroupId in $Groups) {
Write-Host "`nProcessing group: $GroupId" -ForegroundColor Yellow # Detect group type
$Group = $null
try {
$Group = Get-UnifiedGroup -Identity $GroupId -ErrorAction Stop
$GroupType = "UnifiedGroup"
Write-Host " Type: Microsoft 365 Group | Name: $($Group.DisplayName)" -ForegroundColor Green
}
catch {
try {
$Group = Get-DistributionGroup -Identity $GroupId -ErrorAction Stop
$GroupType = "DistributionGroup"
Write-Host " Type: Distribution Group | Name: $($Group.DisplayName)" -ForegroundColor Green
}
catch {
Write-Warning " Group '$GroupId' not found — skipping."
$Results += [PSCustomObject]@{
Group = $GroupId
User = "N/A"
Status = "ERROR – Group not found"
}
continue
}
} foreach ($User in $Users) {
try {
if ($GroupType -eq "UnifiedGroup") {
Add-UnifiedGroupLinks -Identity $GroupId -LinkType Members -Links $User -ErrorAction Stop
}
else {
Add-DistributionGroupMember -Identity $GroupId -Member $User -ErrorAction Stop
} Write-Host " [OK] $User added" -ForegroundColor Green
$Results += [PSCustomObject]@{
Group = $Group.DisplayName
User = $User
Status = "Successfully added"
}
}
catch {
$ErrMsg = $_.Exception.Message if ($ErrMsg -match "already a member") {
Write-Host " [~] $User is already a member" -ForegroundColor DarkYellow
$Results += [PSCustomObject]@{
Group = $Group.DisplayName
User = $User
Status = "Already a member"
}
}
else {
Write-Warning " [ERROR] $User – $ErrMsg"
$Results += [PSCustomObject]@{
Group = $Group.DisplayName
User = $User
Status = "ERROR: $ErrMsg"
}
}
}
}
} # ── Summary ───────────────────────────────────────────────────
Write-Host "`n========== SUMMARY ==========" -ForegroundColor Cyan
$Results | Format-Table -AutoSize # Export results as CSV
$CsvPath = "$PSScriptRoot\Add-UsersToGroups_Results.csv"
$Results | Export-Csv -Path $CsvPath -NoTypeInformation -Encoding UTF8
Write-Host "Results saved to: $CsvPath" -ForegroundColor Cyan Disconnect-ExchangeOnline -Confirm:$false
Write-Host "Disconnected." -ForegroundColor Cyan

How It Works

1. Group type detection The script tries Get-UnifiedGroup first. If that throws, it falls back to Get-DistributionGroup. The detected type is stored and used to select the correct add-member cmdlet for every user in that group.

2. Already a member handling Without this, every pre-existing membership throws a red error and pollutes the output. The script matches the exception message and re-classifies it as a non-fatal warning.

3. CSV audit trail After every run, a results file is written alongside the script. Three possible status values:

Status

Meaning

Successfully added

User was not a member and has been added

Already a member

User was already in the group — no change made

ERROR: ...

Something went wrong — full message appended


Prerequisites and Usage

Install the Exchange Online module if not already present:

==PowerShell==

Install-Module -Name ExchangeOnlineManagement -Scope CurrentUser

Run the script:

==PowerShell==

.\Add-UsersToGroups.ps1

A browser authentication prompt will appear for Exchange Online. After sign-in, the script runs unattended and disconnects automatically.


15 users. 3 groups. 45 clicks saved. One script.

powered by Scribbles