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)