Automating joiner-mover-leaver with the Microsoft Graph PowerShell SDK
A practical pattern for scripting onboarding, transfers and offboarding against Microsoft Graph with unattended, certificate-based authentication.
Every tenant ends up with the same three scripts eventually: one to create a user correctly, one to move them between roles without leaving stale access behind, and one to shut the door completely when they leave. The AzureAD and MSOnline PowerShell modules that used to run these are retired; if you haven’t rebuilt them on the Microsoft Graph PowerShell SDK yet, here’s the pattern I use.
Authenticate like a service, not like a person
The first mistake with Graph PowerShell automation is authenticating with
Connect-MgGraph -Scopes ... — that’s the interactive, delegated flow, and it has no
place in a scheduled task. For unattended scripts, register an app in Entra ID, grant it
application permissions (not delegated), and authenticate with a certificate:
Connect-MgGraph -ClientId $appId -TenantId $tenantId -CertificateThumbprint $thumbprint
Get-MgContext | Select-Object AuthType # should print AppOnly
Application-permission scopes are broader than delegated ones by design — a JML
automation app typically needs User.ReadWrite.All, Group.ReadWrite.All, and
GroupMember.ReadWrite.All. Grant only what each script actually touches, and keep the
onboarding app and the offboarding app as separate app registrations if your change
process requires different approvers for “can create identities” versus “can disable
them.”
Joiner: create the account with everything it needs on day one
$params = @{
accountEnabled = $true
displayName = 'Jan Kowalski'
mailNickname = 'jan.kowalski'
userPrincipalName = 'jan.kowalski@contoso.com'
usageLocation = 'PL' # required before license assignment
passwordProfile = @{
forceChangePasswordNextSignIn = $true
password = $tempPassword
}
}
$newUser = New-MgUser -BodyParameter $params
# Group membership drives most access in a well-run tenant — assign it here, not later
New-MgGroupMember -GroupId $departmentGroupId -DirectoryObjectId $newUser.Id
UsageLocation is easy to forget and blocks license assignment silently until you set it
— put it in the template, not as a follow-up step.
Mover: the step everyone skips
Moving someone between departments is where JML automation usually falls down, because “add the new access” is easy and “remove the old access” requires knowing what they already have. Don’t hand-maintain that list — read it from the directory:
$userId = (Get-MgUser -Filter "userPrincipalName eq 'jan.kowalski@contoso.com'").Id
# What is this user currently a member of?
$currentGroups = Get-MgUserMemberOf -UserId $userId -All
foreach ($group in $currentGroups) {
if ($group.Id -in $groupsToRemove) {
Remove-MgGroupMemberByRef -GroupId $group.Id -DirectoryObjectId $userId
}
}
New-MgGroupMember -GroupId $newDepartmentGroupId -DirectoryObjectId $userId
Remove-MgGroupMemberByRef can’t touch dynamic-membership groups — if your department
groups are dynamic (rule-based on the department attribute), the move is often just:
Update-MgUser -UserId $userId -Department 'Finance'
and the dynamic group engine does the rest within a few minutes. Check which model your tenant uses before you write group-removal logic that a dynamic rule will silently overwrite anyway.
Leaver: disable, revoke, then remove — in that order
The sequence matters more than any single cmdlet here. Disabling the account stops new sign-ins; it does not invalidate tokens already issued.
$userId = (Get-MgUser -Filter "userPrincipalName eq 'jan.kowalski@contoso.com'").Id
# 1. Block new sign-ins immediately
Update-MgUser -UserId $userId -BodyParameter @{ accountEnabled = $false }
# 2. Kill existing sessions and refresh tokens — this is the step people forget
Revoke-MgUserSignInSession -UserId $userId
# 3. Strip group memberships (skips dynamic groups, which resolve on their own)
Get-MgUserMemberOf -UserId $userId -All | ForEach-Object {
Remove-MgGroupMemberByRef -GroupId $_.Id -DirectoryObjectId $userId
}
Revoke-MgUserSignInSession invalidates refresh tokens and session cookies tenant-wide —
without it, a disabled account can still ride an existing access token for up to an hour,
and a cached mobile session considerably longer. For a compromised-account response this
step comes first; for routine offboarding, do it right after disabling the account, before
you start removing group memberships.
Don’t call Remove-MgUser on day one. Deleted users go to a 30-day recycle bin, but
mailbox delegation, license reclamation, and manager handover of files are all easier to
sort out while the account object still exists — disabled, revoked, stripped of access,
but not gone.
Making it idempotent
Every one of these scripts will be re-run against the same user by mistake — someone
re-triggers the workflow, a ticket gets reopened. Guard the joiner script with a
Get-MgUser -Filter existence check before New-MgUser, and guard the leaver script by
checking accountEnabled before you disable it again. Cheap insurance against duplicate
accounts and noisy audit logs.
The takeaway
JML automation isn’t hard to write — it’s hard to keep complete. The mover script is the one that quietly rots because “add access” always gets tested and “remove old access” rarely does. Read current group membership from Graph instead of hand-maintaining a list, revoke sessions before you consider a leaver done, and keep app-only credentials scoped per-script so the disable/delete blast radius matches your change approval process.
Sources & further reading: Create Microsoft 365 user accounts with PowerShell, Block Microsoft 365 user accounts with PowerShell, Revoke access in Microsoft Entra ID, Remove-MgGroupMemberByRef, Use app-only authentication with the Microsoft Graph PowerShell SDK.