Create a Microsoft 365 account for a new starter, assign a licence and add them to groups
Build a workflow action that creates a new starter's Microsoft 365 account, gives it a temporary password, assigns a licence and adds it to groups, so MSPs and IT teams can onboard a user by asking SuperIT.
| Runs in | Cloud sandbox (PowerShell) |
| Uses | Microsoft Graph credential |
| Suggested approval | Require approval from superit:admins for every run |
| Category | User lifecycle |
Test it with SuperIT before you rely on it
This is an example, not a ready-made action, and its script hasn't been tested end to end in a SuperIT tenant. Before you use it, work through it with SuperIT: ask SuperIT to review the script against your environment and requirements, adjust it together, then test it on a test account or device. Test in the action editor runs the script for real.
Why use an action instead of asking SuperIT
SuperIT reads Microsoft 365 through your Microsoft 365 connection, but that connection is read-only, so SuperIT can't change anything in the tenant itself. Creating an account, assigning its licence and adding its groups are writes, which only an action with a delegated Microsoft Graph credential can make. The action sets up every new starter in the same order with the same checks, and pairs with Look up a new starter in your HR system, which can supply its inputs.
To check whether an address is already taken, how many licences are free or which groups a colleague is in, just ask SuperIT in chat.
When to use it
- "Alex Morgan starts on Monday as a sales manager, please set up their account."
- "Create a Microsoft 365 user for our new starter and give them Business Premium."
- "New hire: [email protected], Finance, add her to the Finance team group."
What the action does
- Checks the inputs: every group must be on the script's
$allowedGroupslist, and no user may already have the address as a sign-in name, email address or alias. - Creates the user, enabled, with a random temporary password that must be changed at first sign-in.
- Sets the usage location, which Microsoft needs before it can assign a licence.
- If you give a licence, looks it up by its product code (for example
SPE_E3), checks a seat is free and assigns it. If it can't, it says why and carries on, so you still get the temporary password. - If you give groups, adds the user to each one by exact group name and reports any it couldn't add. It refuses groups that can be assigned Microsoft Entra admin roles.
- Prints the new account details and the temporary password.
What it doesn't do: it doesn't create a mailbox directly (Exchange Online creates one when a licence with Exchange is assigned), add the user to distribution lists or mail-enabled security groups, set a manager, register MFA, or set up a device. It doesn't add the user to SuperIT; do that with Add from MS365 after the next sync (see Connect Microsoft 365).
Before you begin
- A Microsoft Graph credential for user administration, named
M365 Graph, with these scopes:User.ReadWrite.All LicenseAssignment.ReadWrite.All GroupMember.ReadWrite.All. The admin account that signed in needs the User Administrator role. Use this credential only for user-administration actions, and give security, reporting and mail actions their own credentials, so these scopes aren't handed to actions that don't need them. See Automate Microsoft 365 with workflows. - The groups new starters may join. Edit
$allowedGroupsat the top of the script; the action refuses any other group, so a typo or a privileged group, such as one that excludes accounts from Conditional Access, can't be added by mistake. - An authorization policy with one rule: Specific groups
superit:engineers,superit:admins→ Require approval fromsuperit:admins. Don't add an Anyone rule: the script doesn't receive the requester's identity, so an end user could create accounts and add groups. Approvers can see the inputs, including the groups, in the run's details before they approve. - The Engineer or Admin role to build the action in Workflows → Actions. Only admins can create actions in chat.
Build it with SuperIT
Paste this into a chat with SuperIT and adjust the names:
Create a workflow action called "Onboard Microsoft 365 user".
Runtime: PowerShell in the cloud sandbox. Credential: M365 Graph.
Inputs:
- display_name (string, required)
- user_upn (email, required) - the new sign-in address
- usage_location (string, required, default GB) - two-letter country code
- job_title (string, optional)
- department (string, optional)
- licence_sku (string, optional) - licence product code such as SPE_E3
- groups (string, optional) - comma-separated exact group names
It should refuse groups not on an allowed list in the script and role-assignable
groups, fail if any user already has the address as a sign-in name, email or alias,
create the user with a random temporary password that must be changed at first
sign-in, assign the licence if a seat is free, add the groups, and print the
temporary password.
Authorization policy: engineers and admins, every run approved by superit:admins.
SuperIT repeats the brief back, drafts the script, checks it, and creates the action as a draft. Review the script in Workflows → Actions, test it, then publish it.
Or build it yourself
Create the action in Workflows → Actions → Add action, link the M365 Graph credential, add these inputs, and paste the script. Replace the example names in $allowedGroups with your own groups.
| Name | Type | Required | Example |
|---|---|---|---|
display_name |
string | Yes | Alex Morgan |
user_upn |
Yes | [email protected] |
|
usage_location |
string | Yes | GB |
job_title |
string | No | Sales Manager |
department |
string | No | Sales |
licence_sku |
string | No | SPE_E3 |
groups |
string | No | Sales team, All staff |
The main function at the top lists every step in order; each function below it does one of them, and the last line runs it with the action's inputs.
# Goal: create an enabled Microsoft 365 user with a temporary password, assign a licence and
# add allowed groups if given, then output the new account, each step's outcome and the password.
$token = $env:CREDENTIAL_M365_GRAPH_TOKEN
if (-not $token) { throw "Link the M365 Graph credential to this action." }
$headers = @{ Authorization = "Bearer $token" }
$baseUrl = 'https://graph.microsoft.com/v1.0'
# Only these groups can be given to a new starter, so a typo or a privileged group can't slip through.
$allowedGroups = @('All staff', 'Sales team', 'Finance team')
function Invoke-UserOnboarding {
param(
[string]$DisplayName, [string]$UserUpn, [string]$UsageLocation,
[string]$JobTitle, [string]$Department, [string]$LicenceSku, [string]$Groups
)
$name = $DisplayName.Trim()
$upn = $UserUpn.Trim()
$location = $UsageLocation.Trim().ToUpper()
$nickname = $upn.Split('@')[0] -replace '[^A-Za-z0-9._-]', ''
$groupNames = @($Groups -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
Assert-Inputs -Name $name -Upn $upn -Location $location -Nickname $nickname -GroupNames $groupNames
Assert-AddressIsFree -Upn $upn
$password = New-TemporaryPassword
$user = New-M365User -Name $name -Upn $upn -Nickname $nickname -Location $location `
-JobTitle $JobTitle -Department $Department -Password $password
$licence = Add-UserLicence -UserId $user.id -SkuPartNumber $LicenceSku.Trim()
$membership = Add-UserToGroups -UserId $user.id -GroupNames $groupNames
Write-OnboardingSummary -User $user -Upn $upn -Licence $licence -Membership $membership -Password $password
# $script: because the runner returns the script-level $result, not a function's local copy.
$script:result = @{
user = $upn; id = $user.id; licence = $licence.Assigned
groups_added = $membership.Added; groups_failed = $membership.Failed
}
}
function Assert-Inputs {
param([string]$Name, [string]$Upn, [string]$Location, [string]$Nickname, [string[]]$GroupNames)
if (-not $Name) { throw "display_name is required." }
if ($Upn -notmatch '^[^@\s]+@[^@\s]+\.[^@\s]+$') { throw "user_upn must look like [email protected]." }
if (-not $Nickname) { throw "The part of user_upn before the @ needs at least one letter or digit." }
if ($Location -notmatch '^[A-Z]{2}$') { throw "usage_location must be a two-letter country code, such as GB or US." }
$notAllowed = @($GroupNames | Where-Object { $allowedGroups -notcontains $_ })
if ($notAllowed.Count -gt 0) {
throw "Not on this action's allowed group list: $($notAllowed -join ', '). Nothing was changed."
}
}
function Assert-AddressIsFree {
param([string]$Upn)
$value = $Upn.Replace("'", "''")
# Checking mail and aliases too stops a new account clashing with an existing mailbox address.
$filters = @("userPrincipalName eq '$value'", "mail eq '$value'", "proxyAddresses/any(p:p eq 'smtp:$value')")
$lookupHeaders = $headers + @{ ConsistencyLevel = 'eventual' }
foreach ($filter in $filters) {
$uri = "$baseUrl/users?`$filter=$([uri]::EscapeDataString($filter))&`$count=true&`$select=id"
try {
$existing = @(Get-GraphCollection -Uri $uri -RequestHeaders $lookupHeaders)
} catch {
Write-Error "Failed to check whether '$Upn' is in use: $_"
exit 1
}
if ($existing.Count -gt 0) { throw "An account already uses $Upn. Nothing was changed." }
}
}
function New-TemporaryPassword {
$chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!#%*?'
do {
$bytes = [Security.Cryptography.RandomNumberGenerator]::GetBytes(16)
$password = -join ($bytes | ForEach-Object { $chars[$_ % $chars.Length] })
} until ($password -cmatch '[A-Z]' -and $password -cmatch '[a-z]' -and $password -match '\d')
$password
}
function New-M365User {
param(
[string]$Name, [string]$Upn, [string]$Nickname, [string]$Location,
[string]$JobTitle, [string]$Department, [string]$Password
)
$body = @{
accountEnabled = $true
displayName = $Name
userPrincipalName = $Upn
mailNickname = $Nickname
usageLocation = $Location
passwordProfile = @{ password = $Password; forceChangePasswordNextSignIn = $true }
}
if ($JobTitle) { $body.jobTitle = $JobTitle }
if ($Department) { $body.department = $Department }
try {
Invoke-RestMethod -Method Post -Uri "$baseUrl/users" -Headers $headers `
-ContentType 'application/json' -Body ($body | ConvertTo-Json)
} catch {
Write-Error "Failed to create user '$Upn': $_"
exit 1
}
}
function Add-UserLicence {
param([string]$UserId, [string]$SkuPartNumber)
if (-not $SkuPartNumber) { return [pscustomobject]@{ Assigned = 'none'; Note = $null } }
# The account already exists, so report a licence problem and carry on: failing here would
# hide the temporary password.
try {
$sku = Get-GraphCollection -Uri "$baseUrl/subscribedSkus" | Where-Object skuPartNumber -eq $SkuPartNumber
if (-not $sku) { return [pscustomobject]@{ Assigned = 'none'; Note = "$SkuPartNumber not found in this tenant" } }
if ($sku.prepaidUnits.enabled - $sku.consumedUnits -lt 1) {
return [pscustomobject]@{ Assigned = 'none'; Note = "no free $SkuPartNumber seats" }
}
$body = @{ addLicenses = @(@{ skuId = $sku.skuId }); removeLicenses = @() } | ConvertTo-Json -Depth 4
Invoke-RestMethod -Method Post -Uri "$baseUrl/users/$UserId/assignLicense" -Headers $headers `
-ContentType 'application/json' -Body $body | Out-Null
[pscustomobject]@{ Assigned = $sku.skuPartNumber; Note = $null }
} catch {
[pscustomobject]@{ Assigned = 'none'; Note = "failed to assign $($SkuPartNumber): $($_.Exception.Message)" }
}
}
function Add-UserToGroups {
param([string]$UserId, [string[]]$GroupNames)
$added = @(); $failed = @()
foreach ($groupName in $GroupNames) {
try {
Add-UserToGroup -UserId $UserId -GroupName $groupName
$added += $groupName
} catch {
$failed += "$($groupName): $($_.ErrorDetails.Message ?? $_.Exception.Message)"
}
}
[pscustomobject]@{ Added = $added; Failed = $failed }
}
function Add-UserToGroup {
param([string]$UserId, [string]$GroupName)
$filter = [uri]::EscapeDataString("displayName eq '$($GroupName.Replace("'", "''"))'")
$found = @(Get-GraphCollection -Uri "$baseUrl/groups?`$filter=$filter&`$select=id,isAssignableToRole")
if ($found.Count -ne 1) { throw "found $($found.Count) groups with that name" }
if ($found[0].isAssignableToRole) { throw "the group can be assigned admin roles, so this action won't add to it" }
$ref = @{ '@odata.id' = "$baseUrl/directoryObjects/$UserId" } | ConvertTo-Json
Invoke-RestMethod -Method Post -Uri "$baseUrl/groups/$($found[0].id)/members/`$ref" -Headers $headers `
-ContentType 'application/json' -Body $ref | Out-Null
}
function Get-GraphCollection {
param([string]$Uri, [hashtable]$RequestHeaders = $headers)
$items = @()
while ($Uri) {
$page = Invoke-RestMethod -Uri $Uri -Headers $RequestHeaders
$items += $page.value
$Uri = $page.'@odata.nextLink'
}
$items
}
function Write-OnboardingSummary {
param($User, [string]$Upn, $Licence, $Membership, [string]$Password)
$licenceLine = if ($Licence.Note) { "none ($($Licence.Note))" } else { $Licence.Assigned }
Write-Output "=== USER ONBOARDED ==="
Write-Output "User : $($User.displayName) ($Upn)"
Write-Output "Licence : $licenceLine"
Write-Output "Groups added : $($Membership.Added -join ', ')"
Write-Output "Groups not added : $($Membership.Failed -join '; ')"
Write-Output "Temporary password : $Password (must be changed at first sign-in)"
}
Invoke-UserOnboarding -DisplayName $ctx.inputs.display_name -UserUpn $ctx.inputs.user_upn `
-UsageLocation $ctx.inputs.usage_location -JobTitle $ctx.inputs.job_title `
-Department $ctx.inputs.department -LicenceSku $ctx.inputs.licence_sku -Groups $ctx.inputs.groups
The temporary password stays in run history
The script prints the temporary password to Stdout, which is stored with the run. Every engineer and admin in the team, and the approver, can see it in the run's details, and engineers see it in their chat with SuperIT. End users who request a run see a redacted view. The script makes the user change the password at first sign-in. If the run history might be shared, or the new starter doesn't sign in soon, reset the password again before they start.
Test it
Test runs for real
Test skips authorization and approval, uses the real M365 Graph credential, and creates a real account. Use a test sign-in address, and delete the test user in the Microsoft 365 admin center afterwards.
The script is untested, so treat your first test runs as debugging, and don't publish the action until a test run does exactly what you expect.
- Add a test group to
$allowedGroups. - On the action, select Test (or Save and test while editing).
- Enter a test
display_nameanduser_upn, alicence_skuyou have spare seats for, and the test group. - Read the This is not a dry run. warning, tick "I understand this is a real execution that may change real systems." and select Start test run.
- Check Stdout for the
=== USER ONBOARDED ===summary: the licence and group lines, and the temporary password. - Check the user in the Microsoft 365 admin center, then delete it.
- Test again with a group that isn't on the list, and check the run is refused before anything is created.
Use it
Publish the action with Publish (or Save and publish). Engineers then ask SuperIT in chat, for example "Onboard Alex Morgan, [email protected], Sales, with an E3 licence and the Sales team group". SuperIT collects any missing inputs, and the run waits for an admin to approve it. An admin approves it from the run's details, or by asking SuperIT in chat (see Approve or reject a run). SuperIT then reports the result and the temporary password to the engineer. If you don't know a licence's product code, ask SuperIT "Which licences does this tenant have?" first.
For MSPs, publish the action to managed teams from the Publish dialog; an engineer or admin in each team must authorize its own M365 Graph credential. See Automate Microsoft 365 with workflows.
Variations
- Copy the groups of an existing team member instead of listing them. See Copy group memberships from another user.
- Set the user's manager with
PUT /users/{id}/manager/$ref. - Accept several licence codes, comma-separated, and assign them in one
assignLicensecall. - Turn
groupsinto a select input listing the allowed groups, so requesters only see groups they can choose.