PowerShell: Bulk Add Users to Microsoft Entra Groups

This script adds lists of users to one or more Microsoft Entra ID groups in bulk, using the Microsoft Graph PowerShell SDK. It's built for license-group provisioning scenarios where you need to drop a batch of UPNs into specific groups and get a clear record of what happened. You'd run this when onboarding a wave of users, rolling out a licensed feature, or cleaning up group membership after an org change.

How It Works

SDK Version Pinning

The script targets Graph SDK v2.33.x and refuses to run if a newer version is already loaded in the session. This isn’t arbitrary. SDK v2.34+ switched to Windows Authentication Manager (WAM) for sign-in, and a known bug in that version causes every Graph cmdlet to pop its own login prompt. One user, one operation, one popup. It adds up fast. The script checks for pre-WAM modules at startup and exits with a clear error if a newer version is already in memory.

Module Loading

After the version check passes, the script imports the three required Graph modules with a -MaximumVersion cap. If no pre-WAM version is installed at all, it falls back to whatever is available and warns you. It does not silently continue on a broken version.

Graph Connection Handling

Before connecting, the script checks whether a usable Graph session already exists. “Usable” means the token is actually valid, not just present. It does this by firing a cheap API call (/me?$select=id) to prove the token still works. A session with an expired token is actively worse than no session because failed silent refreshes produce the same popup storm as the WAM bug. If the existing session is stale, it disconnects cleanly and reconnects. If scopes are missing, it requests incremental consent rather than starting over.

User and Group Lists

Three user arrays and three group names are defined in the settings region. Each group maps to its own array. Groups with empty arrays are skipped. This structure makes it straightforward to add or remove users without touching any logic.

Group Lookup and Membership Prefetch

For each group, the script resolves the display name to an object ID, then fetches all current member IDs into a HashSet before processing any users. The HashSet is case-insensitive and lets the script check membership locally without a Graph call per user. Adding a user also updates the local set, so duplicate UPNs in your input list won’t attempt a second add.

User Processing and Results

Each UPN is resolved to an Entra user object, then added via the Graph memberByRef endpoint. Each attempt returns Success, AlreadyMember, or Failure. $continueOnError = $true by default, so one bad UPN doesn’t abort the whole run. Results are tracked and printed in a summary at the end.

Logging

Every action is written to a timestamped log file under C:Logs. The log directory is created if it doesn’t exist. If the log file can’t be created, the script warns you and continues without logging rather than stopping.

Usage

Prerequisites

  • PowerShell 5.1 or 7.x, running in a plain console window. Not ISE, not an embedded terminal in VS Code or Windows Terminal if WAM can’t render there.
  • Microsoft Graph PowerShell SDK. v2.33.0 is strongly preferred:
Install-Module Microsoft.Graph.Authentication, Microsoft.Graph.Groups, Microsoft.Graph.Users -RequiredVersion 2.33.0 -Scope CurrentUser -ForceCode language: PowerShell (powershell)
  • An account with permission to read Entra users and groups, and to manage group membership. The script requests these scopes at sign-in:

GroupMember.ReadWrite.All
Group.ReadWrite.All
User.Read.All

Configuration

Edit the settings region before running. Add UPNs to the appropriate arrays. Leave an array empty to skip that group.

$copilotUsers = @(
    "[email protected]",
    "[email protected]"
)Code language: PowerShell (powershell)

Change the log path if needed:

$logFile = "C:LogsAddUsersToGroup_{0}.log" -f (Get-Date -Format 'yyyyMMdd_HHmmss')Code language: PowerShell (powershell)

Set $continueOnError = $false if you want the script to stop on the first failure instead of moving on.

Running the Script

Open a fresh PowerShell window. Do not run Connect-MgGraph first.

.Add-UsersToEntraGroups.ps1Code language: PowerShell (powershell)

One interactive sign-in prompt will appear. After that, the script runs to completion.

Caveats

Fresh Window Is Not Optional

If you run Connect-MgGraph before starting the script, or if any other script in the same session has loaded a newer Graph SDK version, the v2.33 pin cannot take effect. .NET loads assemblies once and does not unload them. Remove-Module does not help here. The script detects this and exits with an error, but you still have to close the window and start over.

WAM Fallback Warning Is Not Noise

If v2.33 isn’t installed and the script falls back to v2.34+, you may hit the repeated-login-popup bug mid-run. The script warns you, but it won’t stop you. Install v2.33 if you’re doing a large batch.

Group Names Must Be Exact

Lookup is by displayName with an exact filter match. Partial names, wrong casing, or extra spaces will result in a “not found” error. If multiple groups share the same display name, the script throws an error and skips that group.

Membership Check Is Local After Prefetch

The prefetch snapshot is taken once at the start of each group’s processing loop. If another process adds the same user to the same group between prefetch and your add attempt, the Graph API will return an error (the user already exists), which gets caught and logged as a failure rather than silently ignored.

No Removal Logic

This script only adds. It does not remove users who are in the group but not in your list. If you need to sync membership, you’ll need additional logic.

Log Directory Must Be Writable

The script targets C:Logs by default. If that path can’t be created or written to, logging is disabled for the rest of the run. The script does not stop, but you lose the audit trail.

The Graph Session Is Left Open

The script intentionally skips Disconnect-MgGraph at the end so you can re-run or chain other Graph operations in the same window. If your security policy requires explicit disconnection, add it manually.

Full Script

# Add Users to Microsoft Entra Licensing Groups

# Graph SDK 2.34+ forces WAM sign-in, and a known bug breaks its token cache so
# EVERY cmdlet pops its own login box. v2.33 uses browser sign-in and is
# unaffected. Prefer a pre-WAM version when installed:
#   Install-Module Microsoft.Graph.Authentication,Microsoft.Graph.Groups,Microsoft.Graph.Users -RequiredVersion 2.33.0 -Scope CurrentUser -Force
#
# IMPORTANT: run this script in a FRESH PowerShell window, and do NOT run
# Connect-MgGraph yourself first — that autoloads the newest SDK, .NET cannot
# unload its assemblies, and the v2.33 pin below then can't take effect.
# The script signs in by itself.
$ErrorActionPreference = 'Stop'

$graphModules = 'Microsoft.Graph.Authentication','Microsoft.Graph.Groups','Microsoft.Graph.Users'
$preWamMax = [version]'2.33.99'
$allHavePreWam = $true
foreach ($m in $graphModules) {
    if (-not (Get-Module -ListAvailable -Name $m | Where-Object { $_.Version -le $preWamMax })) { $allHavePreWam = $false; break }
}

# Once a newer Graph assembly is loaded, .NET can't unload it and v2.33 can't
# load — Remove-Module doesn't help. Fail fast with instructions instead of
# silently continuing on the WAM-broken version.
$loadedNewer = @(Get-Module -Name $graphModules | Where-Object { $_.Version -gt $preWamMax })
if ($allHavePreWam -and $loadedNewer.Count -gt 0) {
    Write-Host "ERROR: Graph SDK v$($loadedNewer[0].Version) is already loaded in this session, so the pinned v2.33 modules cannot load." -ForegroundColor Red
    Write-Host "       Open a FRESH PowerShell window and run this script directly." -ForegroundColor Red
    Write-Host "       Do NOT run Connect-MgGraph first - the script signs in by itself." -ForegroundColor Red
    exit 1
}

try {
    foreach ($m in $graphModules) {
        if ($allHavePreWam) { Import-Module $m -MaximumVersion $preWamMax -ErrorAction Stop }
        else                { Import-Module $m -ErrorAction Stop }
    }
}
catch {
    Write-Host "ERROR: Failed to load the Microsoft Graph modules: $_" -ForegroundColor Red
    exit 1
}

$authVersion = (Get-Module Microsoft.Graph.Authentication).Version
if ($authVersion -ge [version]'2.34.0') {
    Write-Host "WARNING: Microsoft.Graph.Authentication v$authVersion forces WAM sign-in, which has a known repeated-login-popup bug." -ForegroundColor Yellow
    Write-Host "         Fix: install v2.33.0 of the three Graph modules (command in the comments at the top of this script) and re-run from a fresh window." -ForegroundColor Yellow
}

#region Script Settings
# ===================================================
# SETTINGS: CONFIGURE THESE VALUES BEFORE RUNNING
# ===================================================

# Users for "LicensingGroup1"
$group1Users = @(
    "[email protected]"
    "[email protected]"
)

# Users for "LicensingGroup2"
$group2Users = @(
    "[email protected]"
    "[email protected]"
)

# Users for "LicensingGroup3"
$group3Users = @(
    "[email protected]"
    "[email protected]"
)

# Group-to-users mapping (group display name → user array)
$groupAssignments = @(
    @{ GroupName = "LicensingGroup1";  Users = $group1Users }
    @{ GroupName = "LicensingGroup2";  Users = $group2Users }
    @{ GroupName = "LicensingGroup3";  Users = $group3Users }
)

$logFile = "C:\Logs\AddUsersToEntraGroups_{0}.log" -f (Get-Date -Format 'yyyyMMdd_HHmmss')
$enableLogging = $true
$continueOnError = $true
#endregion Script Settings

#region Functions
function Write-LogMessage {
    param(
        [Parameter(Mandatory)]
        [string]$Message,
        [ValidateSet("INFO","WARNING","ERROR","SUCCESS")]
        [string]$Level = "INFO"
    )

    $colors = @{
        "INFO"    = "White"
        "WARNING" = "Yellow"
        "ERROR"   = "Red"
        "SUCCESS" = "Green"
    }

    $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
    $formattedMessage = "[{0}] [{1}] {2}" -f $timestamp, $Level, $Message

    Write-Host $formattedMessage -ForegroundColor $colors[$Level]

    if ($script:enableLogging) {
        try {
            Add-Content -Path $script:logFile -Value $formattedMessage -ErrorAction Stop
        }
        catch {
            Write-Host "Failed to write to log file: $_" -ForegroundColor Red
            $script:enableLogging = $false
        }
    }
}

function Get-EntraGroupByName {
    param([Parameter(Mandatory)][string]$DisplayName)

    $safeName = $DisplayName -replace "'", "''"
    $group = Get-MgGroup -Filter "displayName eq '$safeName'" -ErrorAction Stop
    if (-not $group) {
        throw "Group '$DisplayName' not found in Entra ID"
    }
    if ($group.Count -gt 1) {
        throw "Multiple groups found matching '$DisplayName'. Use a more specific name."
    }
    return $group
}

function Get-EntraUserByUPN {
    param([Parameter(Mandatory)][string]$UPN)

    $user = Get-MgUser -UserId $UPN -ErrorAction Stop
    if (-not $user) {
        throw "User '$UPN' not found in Entra ID"
    }
    return $user
}

function Get-EntraGroupMemberIds {
    param([Parameter(Mandatory)][string]$GroupId)

    $memberIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    Get-MgGroupMember -GroupId $GroupId -All -Property Id -ErrorAction Stop |
        ForEach-Object { [void]$memberIds.Add($_.Id) }
    return , $memberIds
}

function Add-EntraUserToGroup {
    param(
        [Parameter(Mandatory)][string]$UserId,
        [Parameter(Mandatory)][string]$UserUPN,
        [Parameter(Mandatory)][string]$GroupId,
        [Parameter(Mandatory)][string]$GroupName,
        [Parameter(Mandatory)][System.Collections.Generic.HashSet[string]]$MemberIds
    )

    try {
        if ($MemberIds.Contains($UserId)) {
            Write-LogMessage -Message "User '$UserUPN' already in group '$GroupName'" -Level "WARNING"
            return "AlreadyMember"
        }

        $body = @{ "@odata.id" = "https://graph.microsoft.com/v1.0/directoryObjects/$UserId" }
        New-MgGroupMemberByRef -GroupId $GroupId -BodyParameter $body -ErrorAction Stop
        [void]$MemberIds.Add($UserId)

        Write-LogMessage -Message "Added user '$UserUPN' to group '$GroupName'" -Level "SUCCESS"
        return "Success"
    }
    catch {
        Write-LogMessage -Message "Failed to add '$UserUPN' to '$GroupName'. Error: $_" -Level "ERROR"
        return "Failure"
    }
}
#endregion Functions

#region Main Script
# Initialize log file
if ($enableLogging) {
    try {
        $logDir = Split-Path -Path $logFile -Parent
        if (-not (Test-Path -Path $logDir)) {
            New-Item -Path $logDir -ItemType Directory -Force | Out-Null
        }

        $allUsers = @(($group1Users + $group2Users + $group3Users) | Sort-Object -Unique)
        $logHeader = @"
==================================================
  Add Users to Entra Licensing Groups
  Date: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')
  Groups: $($groupAssignments.GroupName -join ', ')
  Total unique users: $($allUsers.Count)
==================================================
"@
        Set-Content -Path $logFile -Value $logHeader -ErrorAction Stop
        $script:logFile = $logFile
        $script:enableLogging = $enableLogging
        Write-LogMessage -Message "Log file initialized at '$logFile'"
    }
    catch {
        Write-Host "Failed to create log file. Continuing without logging. Error: $_" -ForegroundColor Red
        $enableLogging = $false
        $script:enableLogging = $false
    }
}

Write-Host "========================================================" -ForegroundColor Cyan
Write-Host "  Add Users to Entra Licensing Groups" -ForegroundColor Cyan
Write-Host "========================================================" -ForegroundColor Cyan
Write-Host ""

# Connect to Microsoft Graph.
# A STALE session is worse than none: when the cached token expires and silent
# refresh fails, every individual Graph cmdlet throws its own interactive login
# prompt (the popup storm) and those sign-ins don't persist. So an existing
# session is only reused after proving its token still works; otherwise do ONE
# clean explicit Connect-MgGraph, which does persist.
Write-LogMessage -Message "Checking Microsoft Graph connection..."
$requiredScopes = @("GroupMember.ReadWrite.All", "Group.Read.All", "User.Read.All")

try {
    $context = Get-MgContext
    $missingScopes = @($requiredScopes | Where-Object { -not $context -or $context.Scopes -notcontains $_ })

    $needConnect = $true
    if ($context -and $missingScopes.Count -eq 0) {
        # Probe with a cheap call to prove the token is actually usable
        try {
            Invoke-MgGraphRequest -Method GET -Uri 'https://graph.microsoft.com/v1.0/me?$select=id' -ErrorAction Stop | Out-Null
            Write-LogMessage -Message "Reusing existing Graph session ($($context.Account))" -Level "SUCCESS"
            $needConnect = $false
        }
        catch {
            Write-LogMessage -Message "Existing session is stale (token no longer refreshes). Reconnecting cleanly..." -Level "WARNING"
            Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null
        }
    }
    elseif ($context) {
        # Missing scopes: do NOT disconnect — incremental consent adds them to the grant
        Write-LogMessage -Message "Session missing scopes ($($missingScopes -join ', ')). Requesting additional consent..." -Level "WARNING"
    }
    else {
        Write-LogMessage -Message "No active Graph session. Signing in..."
    }

    if ($needConnect) {
        # Must run in a plain PowerShell console: WAM sign-in can't render in
        # ISE/embedded terminals, and device-code auth is blocked by Conditional
        # Access in this tenant. ONE interactive prompt here is expected.
        Connect-MgGraph -Scopes $requiredScopes -NoWelcome -ErrorAction Stop
        Write-LogMessage -Message "Connected to Microsoft Graph as $((Get-MgContext).Account)" -Level "SUCCESS"
    }
}
catch {
    Write-LogMessage -Message "Failed to connect to Microsoft Graph: $_" -Level "ERROR"
    Write-LogMessage -Message "If the sign-in window failed to appear, run this from a plain PowerShell console (not ISE / embedded terminal)." -Level "ERROR"
    exit 1
}

$totalAttempts = 0
$successCount = 0
$alreadyMemberCount = 0
$failureCount = 0

foreach ($assignment in $groupAssignments) {
    $groupName = $assignment.GroupName
    $users = @($assignment.Users)

    if ($users.Count -eq 0) {
        Write-LogMessage -Message "No users specified for group '$groupName'. Skipping." -Level "WARNING"
        continue
    }

    Write-Host ""
    Write-LogMessage -Message "--- Processing group: '$groupName' ($($users.Count) users) ---"

    try {
        $group = Get-EntraGroupByName -DisplayName $groupName
        Write-LogMessage -Message "Found group '$groupName' (ID: $($group.Id))" -Level "SUCCESS"
        $memberIds = Get-EntraGroupMemberIds -GroupId $group.Id
        Write-LogMessage -Message "Fetched current membership for '$groupName' ($($memberIds.Count) members)"
    }
    catch {
        Write-LogMessage -Message "Group lookup failed for '$groupName': $_" -Level "ERROR"
        $failureCount += $users.Count
        if (-not $continueOnError) { exit 1 }
        continue
    }

    foreach ($upn in $users) {
        $totalAttempts++

        try {
            $user = Get-EntraUserByUPN -UPN $upn
        }
        catch {
            Write-LogMessage -Message "User '$upn' not found in Entra ID. Skipping." -Level "ERROR"
            $failureCount++
            if (-not $continueOnError) { exit 1 }
            continue
        }

        $result = Add-EntraUserToGroup -UserId $user.Id -UserUPN $upn -GroupId $group.Id -GroupName $groupName -MemberIds $memberIds
        switch ($result) {
            "Success"       { $successCount++ }
            "AlreadyMember" { $alreadyMemberCount++ }
            "Failure"       {
                $failureCount++
                if (-not $continueOnError) { exit 1 }
            }
        }
    }
}

# Note: intentionally NOT disconnecting — keeps the Graph session alive for subsequent runs

# Summary
Write-Host ""
Write-Host "========================================================" -ForegroundColor Cyan
Write-Host "  Summary" -ForegroundColor Cyan
Write-Host "========================================================" -ForegroundColor Cyan
Write-Host ("Total attempts: {0}" -f $totalAttempts)        -ForegroundColor White
Write-Host ("Successfully added: {0}" -f $successCount)      -ForegroundColor Green
Write-Host ("Already a member: {0}" -f $alreadyMemberCount)  -ForegroundColor Yellow
Write-Host ("Failed to add: {0}" -f $failureCount)           -ForegroundColor Red

if ($enableLogging) {
    Write-Host ""
    Write-Host "Log file created at: $logFile" -ForegroundColor Cyan
}
#endregion Main Script
Code language: PowerShell (powershell)