Onboarding 200 new hires by hand in the M365 admin center is no one's idea of a good time. With the Microsoft Graph PowerShell SDK, the same job is a 50-line script that reads a CSV, creates each user, assigns the right license, drops them into the correct security and distribution groups, and emails the temporary password to the manager — in under a minute.
This guide is the production version of that script. We will design the CSV, build the user-creation loop, handle license assignment via SKUs, manage group membership, deal with the inevitable failures, and — critically — handle the temporary password without writing it to disk. Free PDF cheat sheet at the bottom.
Table of Contents
Prerequisites
- PowerShell 7.x
Microsoft.Graph.Authentication,Microsoft.Graph.Users,Microsoft.Graph.Groups,Microsoft.Graph.Identity.DirectoryManagement- App registration (or interactive admin) with delegated/application scopes:
User.ReadWrite.All,Group.ReadWrite.All,Organization.Read.All(for license SKUs) - A CSV with the new hires
CSV design
Keep the CSV simple but complete. Every column maps directly to a user property or a downstream action:
FirstName,LastName,UPN,Department,JobTitle,Office,ManagerUPN,License,Groups
Alice,Smith,alice.smith@contoso.com,Engineering,Senior Dev,Berlin,bob@contoso.com,SPE_E3,sg-eng;dl-eng-news
Carol,Jones,carol.jones@contoso.com,Sales,Account Exec,London,dave@contoso.com,SPE_E5,sg-sales;dl-sales-news
Notes on the columns:
UPN= User Principal Name. Must use a verified domain in your tenant.License= the SKU part number.SPE_E3= Microsoft 365 E3,SPE_E5= E5,EXCHANGESTANDARD= Exchange Online Plan 1, etc. Discover them withGet-MgSubscribedSku | Format-Table SkuPartNumber, ConsumedUnits, PrepaidUnits.Groups= semicolon-separated list of group display names (we will look up the IDs).
Authenticate with the right scopes
Connect-MgGraph -Scopes @(
"User.ReadWrite.All",
"Group.ReadWrite.All",
"Organization.Read.All"
)
Get-MgContext # confirm
Create the user
function New-RandomTempPassword {
param([int]$Length = 16)
-join ((33..126) | Where-Object { $_ -notin 34,39,96 } |
Get-Random -Count $Length | ForEach-Object { [char]$_ })
}
$user = New-MgUser `
-AccountEnabled `
-DisplayName "$($row.FirstName) $($row.LastName)" `
-UserPrincipalName $row.UPN `
-MailNickname ($row.UPN -split '@')[0] `
-GivenName $row.FirstName -Surname $row.LastName `
-Department $row.Department -JobTitle $row.JobTitle `
-OfficeLocation $row.Office `
-PasswordProfile @{
Password = New-RandomTempPassword
ForceChangePasswordNextSignIn = $true
}
ForceChangePasswordNextSignIn = $true guarantees the user resets the temp password on first login. Never store the temp password — pass it directly to the secure delivery mechanism (manager email through your mail flow, Slack via webhook, password manager link).
License assignment
# Look up the SKU once at script start
$skus = Get-MgSubscribedSku
$skuId = ($skus | Where-Object SkuPartNumber -eq $row.License).SkuId
if (-not $skuId) {
throw "Unknown license SKU: $($row.License)"
}
Set-MgUserLicense -UserId $user.Id -AddLicenses @(@{ SkuId = $skuId }) -RemoveLicenses @()
If your tenant has limits on certain plans, watch ConsumedUnits vs PrepaidUnits on the SKU — assigning beyond the prepaid count fails.
Group membership
# Build a one-time lookup of all groups the CSV mentions
$groupCache = @{}
$row.Groups -split ';' | ForEach-Object {
$name = $_.Trim()
if (-not $groupCache.ContainsKey($name)) {
$g = Get-MgGroup -Filter "displayName eq '$name'"
if ($g) { $groupCache[$name] = $g.Id }
}
}
foreach ($g in ($row.Groups -split ';')) {
$name = $g.Trim()
if ($groupCache[$name]) {
New-MgGroupMember -GroupId $groupCache[$name] -DirectoryObjectId $user.Id
} else {
Write-Warning "Group not found: $name"
}
}
Set the manager attribute
if ($row.ManagerUPN) {
$manager = Get-MgUser -UserId $row.ManagerUPN -ErrorAction SilentlyContinue
if ($manager) {
Set-MgUserManagerByRef -UserId $user.Id -BodyParameter @{
"@odata.id" = "https://graph.microsoft.com/v1.0/users/$($manager.Id)"
}
}
}
The full script
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string]$CsvPath,
[string]$LogPath = ".\provisioning.log"
)
$ErrorActionPreference = 'Stop'
Connect-MgGraph -Scopes @("User.ReadWrite.All","Group.ReadWrite.All","Organization.Read.All")
$skus = Get-MgSubscribedSku
$rows = Import-Csv $CsvPath
$results = foreach ($r in $rows) {
try {
# ... (combine all the steps above) ...
[PSCustomObject]@{ UPN = $r.UPN; Status = 'created'; Error = '' }
} catch {
Write-Warning "FAILED $($r.UPN): $($_.Exception.Message)"
[PSCustomObject]@{ UPN = $r.UPN; Status = 'failed'; Error = $_.Exception.Message }
}
}
$results | Export-Csv $LogPath -NoTypeInformation
$results | Group-Object Status | Format-Table -AutoSize
Disconnect-MgGraph
Error handling and idempotency
Two things bite every first run:
- UPN already exists. Your script must decide: skip, fail, or update. Add a check at the top of the loop:
$existing = Get-MgUser -Filter "userPrincipalName eq '$($r.UPN)'" -ErrorAction SilentlyContinue
if ($existing) {
Write-Warning "Skipping $($r.UPN) - already exists"
continue
}
- License capacity exceeded. Add a pre-check before the loop:
$needed = ($rows | Group-Object License | ForEach-Object { @{ Sku = $_.Name; Count = $_.Count } })
foreach ($n in $needed) {
$sku = $skus | Where-Object SkuPartNumber -eq $n.Sku
$available = $sku.PrepaidUnits.Enabled - $sku.ConsumedUnits
if ($n.Count -gt $available) {
throw "Not enough $($n.Sku) licenses: need $($n.Count), have $available"
}
}
Cheat sheet
The whole script + license SKU table + scopes on one PDF: M365 Bulk Provisioning Cheat Sheet.
FAQ
How do I find the right SkuPartNumber for my license?
Get-MgSubscribedSku | Format-Table SkuPartNumber, SkuId, ConsumedUnits, @{n='Available';e={$_.PrepaidUnits.Enabled - $_.ConsumedUnits}} lists every SKU your tenant owns, what each is called, and how many seats are free.
Can I assign Group-Based Licensing instead of direct license assignment?
Yes — and it is the recommended pattern at scale. Set the license on a security group via the Entra portal once; this script then only needs to add the user to the group. The license assignment happens automatically.
How do I deliver the temporary password securely?
Either email the manager via Mail.Send (delegated) using a templated message, or write to a one-time-link service. Never write the password to a CSV or log file.
Does the user need a license to be created?
No — a user can exist without a license. They cannot sign in to mailboxes / Teams / OneDrive without one, but the directory object is fine.
What if my tenant uses dynamic groups?
Skip the explicit New-MgGroupMember step for those — Entra ID adds the user automatically once their attributes match the rule. Set the matching attributes (Department, JobTitle, etc.) on the user instead.
Can I use this from Azure Automation?
Yes — switch from interactive Connect-MgGraph to certificate-based application auth, store the cert in the Automation account, and run as a runbook. The script body itself does not change.
What about Exchange-only attributes (alias, mailbox region)?
Most are settable via Graph (mailNickname, preferredDataLocation). A few legacy ones still need the Exchange Online module — combine the two in your script.