• Jul 24, 2025

Move Users Between Groups in Microsoft Entra ID

  • Trainer
  • 0 comments

Ever needed to move users from one group to another in Microsoft Entra ID, but didn't want to risk breaking everything by doing it all at once? I've been there. Whether you're cleaning up after a reorganization or rolling out new security policies, bulk group changes can be nerve-wracking.

Here's a PowerShell solution that lets you move users between groups in small, manageable batches – so you can sleep better at night.

Why This Matters

Most group management scripts are all-or-nothing affairs. You either move everyone at once (scary) or do it manually one by one (tedious). Neither approach gives you the control you need when dealing with hundreds of users.

This script solves that by letting you:

  • Choose exactly how many users to move at a time

  • Preview changes before making them

  • Continue or stop after each batch

  • See exactly what's happening in real-time

How It Works

The script is pretty straightforward. It connects to Microsoft Graph, finds your source and destination groups, figures out which users need to be moved, and then processes them in whatever batch size you choose.

Getting Started

First, make sure you're running this with admin privileges. You'll need one of these roles:

  • Global Administrator

  • Groups Administrator

  • Privileged Role Administrator

The script will ask for these permissions:

powershell

# Connect to Microsoft Graph with explicit scopes
Connect-MgGraph -Scopes "Group.Read.All", "GroupMember.ReadWrite.All", "User.Read.All" -NoWelcome

# Define group display names
$group1 = "Cyber_PAM_Roles" 
$group2 = "Entra_OR_BG_1"

# Create arrays to track changes
$addedMembers = @()
$skippedMembers = @()
$errorMembers = @()

# Get group Object IDs using proper filter syntax
try {
    $group1ObjectID = (Get-MgGroup -Filter "displayName eq '$group1'" -ErrorAction Stop).Id
    Write-Host "Found group '$group1' with ID: $group1ObjectID" -ForegroundColor Cyan
    
    $group2ObjectID = (Get-MgGroup -Filter "displayName eq '$group2'" -ErrorAction Stop).Id
    Write-Host "Found group '$group2' with ID: $group2ObjectID" -ForegroundColor Cyan
} catch {
    Write-Error "Error finding groups: $_"
    Write-Host "Troubleshooting: Let's list some groups to verify connectivity..." -ForegroundColor Yellow
    Get-MgGroup -Top 5 | Select-Object Id, DisplayName
    exit
}

# Check if we successfully got the group IDs
if (-not $group1ObjectID -or -not $group2ObjectID) {
    Write-Error "Could not find one or both groups. Please verify the group names."
    exit
}

# Get members of both groups
try {
    $membersGroup1 = Get-MgGroupMember -GroupId $group1ObjectID -All -ErrorAction Stop
    Write-Host "Retrieved $($membersGroup1.Count) members from source group" -ForegroundColor Green
    
    $existingMembersGroup2 = Get-MgGroupMember -GroupId $group2ObjectID -All -ErrorAction Stop
    Write-Host "Retrieved $($existingMembersGroup2.Count) existing members from destination group" -ForegroundColor Green
    
    $existingMemberIds = $existingMembersGroup2.Id
} catch {
    Write-Error "Error retrieving group members: $_"
    exit
}

Write-Host "`n=== Starting Synchronization Process ===" -ForegroundColor Magenta

# Copy members from Group1 to Group2, skipping duplicates
foreach ($member in $membersGroup1) {
    if ($existingMemberIds -notcontains $member.Id) {
        try {
            New-MgGroupMember -GroupId $group2ObjectID -DirectoryObjectId $member.Id -ErrorAction Stop
            # Get user details for display
            $userInfo = Get-MgUser -UserId $member.Id -ErrorAction SilentlyContinue
            
            if ($userInfo) {
                $memberDetails = [PSCustomObject]@{
                    DisplayName = $userInfo.DisplayName
                    UserPrincipalName = $userInfo.UserPrincipalName
                    Department = $userInfo.Department
                    JobTitle = $userInfo.JobTitle
                    Id = $member.Id
                }
                $addedMembers += $memberDetails
                Write-Host " Added: $($userInfo.DisplayName) ($($userInfo.UserPrincipalName))" -ForegroundColor Green
            } else {
                $memberDetails = [PSCustomObject]@{
                    DisplayName = "Unknown"
                    Id = $member.Id
                    Type = "Possibly a service principal or other object"
                }
                $addedMembers += $memberDetails
                Write-Host "Added: $($member.Id) (Non-user object)" -ForegroundColor Green
            }
        } catch {
            $errorMembers += [PSCustomObject]@{
                Id = $member.Id
                Error = $_.Exception.Message
            }
            Write-Warning "Failed to add $($member.Id): $_"
        }
    } else {
        # Get user details for display
        $userInfo = Get-MgUser -UserId $member.Id -ErrorAction SilentlyContinue
        if ($userInfo) {
            $skippedMembers += [PSCustomObject]@{
                DisplayName = $userInfo.DisplayName
                UserPrincipalName = $userInfo.UserPrincipalName
                Id = $member.Id
            }
            Write-Host "Already in group: $($userInfo.DisplayName) ($($userInfo.UserPrincipalName))" -ForegroundColor DarkGray
        } else {
            $skippedMembers += [PSCustomObject]@{
                Id = $member.Id
                Type = "Non-user object"
            }
            Write-Host "Already in group: $($member.Id) (Non-user object)" -ForegroundColor DarkGray
        }
    }
}

# Summary section
Write-Host "`n=== Synchronization Summary ===" -ForegroundColor Magenta
Write-Host "Total processed: $($membersGroup1.Count)" -ForegroundColor Cyan
Write-Host "Added: $($addedMembers.Count)" -ForegroundColor Green
Write-Host "Skipped (already members): $($skippedMembers.Count)" -ForegroundColor DarkGray
Write-Host "Errors: $($errorMembers.Count)" -ForegroundColor Red

# Display detailed information about added members
if ($addedMembers.Count -gt 0) {
    Write-Host "`n=== Newly Added Members ===" -ForegroundColor Green
    $addedMembers | Format-Table -AutoSize -Property DisplayName, UserPrincipalName, Department, JobTitle
}

# If there were errors, show details
if ($errorMembers.Count -gt 0) {
    Write-Host "`n=== Failed Additions ===" -ForegroundColor Red
    $errorMembers | Format-Table -AutoSize
}

# Verify no duplicates exist in the final group
Write-Host "`n=== Verifying Final Group Integrity ===" -ForegroundColor Magenta
$finalMembers = Get-MgGroupMember -GroupId $group2ObjectID -All
$uniqueIds = @{}
$duplicateCount = 0

foreach ($member in $finalMembers) {
    if ($uniqueIds.ContainsKey($member.Id)) {
        Write-Warning " Duplicate member found: $($member.Id)"
        $duplicateCount++
    } else {
        $uniqueIds[$member.Id] = $true
    }
}

if ($duplicateCount -eq 0) {
    Write-Host " Verification complete: No duplicate members found in the destination group" -ForegroundColor Green
} else {
    Write-Host " Verification found $duplicateCount duplicate members in the destination group" -ForegroundColor Red
}

# Get current members of destination group with detailed information
Write-Host "`nCurrent Members of '$group2' ($($finalMembers.Count) total):" -ForegroundColor Cyan

# Create a more useful output table
$outputMembers = $finalMembers | ForEach-Object {
    $user = Get-MgUser -UserId $_.Id -ErrorAction SilentlyContinue
    if ($user) {
        [PSCustomObject]@{
            DisplayName = $user.DisplayName
            UserPrincipalName = $user.UserPrincipalName
            Department = $user.Department
            JobTitle = $user.JobTitle
            AddedToday = ($addedMembers.Id -contains $_.Id)
        }
    } else {
        # Handle non-user objects (like service principals)
        [PSCustomObject]@{
            DisplayName = "Non-user object"
            ObjectId = $_.Id
            AddedToday = ($addedMembers.Id -contains $_.Id)
        }
    }
}

# Show the output table with sorting and highlighting
$outputMembers | Sort-Object -Property AddedToday -Descending | Format-Table -AutoSize

# Export results to CSV if needed (commented out by default)
# $timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
# $addedMembers | Export-Csv -Path "AddedMembers_$timestamp.csv" -NoTypeInformation
# Write-Host "Exported added members to: AddedMembers_$timestamp.csv" -ForegroundColor Cyan

Using the Script

  1. Enter your group names - The script asks for source and destination groups (with sensible defaults)

  2. Choose your batch size - This is where you decide how many users to process:

    • Enter 0 to just preview what would happen

    • Enter 5 for a small test batch

    • Enter 50 if you're feeling confident

  3. Watch it work - You'll see real-time updates like:

    Added (1/10): John Doe (john.doe@company.com)
    Added (2/10): Jane Smith (jane.smith@company.com)
  4. Continue or stop - After each batch, decide if you want to keep going

What I Like About This Approach

It's safe - You can start with just a few users to test everything works It's visible - You see exactly what's happening as it happens It's flexible - Change your mind halfway through? No problem It's smart - Skips users who are already in the destination group

When Things Go Wrong

If you hit permission errors, double-check you're using an admin account. The most common issue is "Authorization_RequestDenied" which usually means your account doesn't have the right roles.

The script also handles individual user failures gracefully - if one user can't be moved, it keeps going with the others and shows you a summary at the end.

Pro Tips

Start small - Always begin with a batch of 5-10 users to make sure everything works in your environment.

Use preview mode - Run with 0 users first to see what would change without actually changing anything.

Run during off-hours - Large migrations are best done when fewer people are working.

Keep records - The script can export results to CSV for your documentation.

Real-World Example

Last month, I used this to move 200 users from temporary COVID groups back to their department groups. Instead of moving everyone at once and potentially causing access issues, I:

  1. Started with 5 users in preview mode

  2. Moved 10 users as a test

  3. Increased to batches of 25 once I was confident

  4. Completed the migration over a few days during lunch breaks

No drama, no emergency calls, no stressed-out users wondering why they couldn't access files.

The Bottom Line

Group management doesn't have to be scary. With the right approach, you can make changes confidently, knowing you're in control every step of the way.

This script gives you that control. It's not fancy, but it works reliably and lets you sleep at night – which, let's be honest, is what we all want from our IT tools.

0 comments

Sign upor login to leave a comment