← All posts
powershellentra-idautomationmicrosoft-graph

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.