Block a leaver's sign-in, revoke sessions, scramble the password, remove groups and free their licences
Build a workflow action that locks a leaver out of Microsoft 365 and frees their licences in one approved run, so MSPs and IT teams can offboard 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. Blocking a leaver, revoking their sessions and removing their groups and licences are writes, which only an action with a delegated Microsoft Graph credential can make. The order matters (block sign-in and scramble the password, then revoke sessions, then remove access), and a reviewed script won't skip a step at 6 pm on a Friday.
To check what a leaver still has, such as their licences, groups, admin roles or inbox rules, just ask SuperIT in chat.
When to use it
- "Jane left today, please offboard her."
- "Disable Tom Baker's account and take his licence back."
- "Leaver: [email protected], last day Friday."
What the action does
- Checks the inputs and that the user exists. It refuses accounts synced from on-premises Active Directory, which must be disabled there.
- Refuses protected accounts: the admin account behind the credential, any account on the script's
$protectedUpnslist, and accounts that hold a Microsoft Entra admin role directly or through a role-assignable group. It doesn't see roles that are only eligible through Privileged Identity Management. - Blocks sign-in.
- Sets a long random password that nobody is told.
- Revokes all sign-in sessions, so Microsoft 365 apps ask the user to sign in again. It does this after the block and the password change, so nobody can get a new session with the old password in between.
- Removes the user from every group it can: Microsoft 365 groups and security groups with assigned membership. If
remove_licencesis off, it leaves the user in any group that assigns them a licence, because leaving that group would remove the licence anyway. - If
remove_licencesis on, removes licences assigned directly to the user. - Prints a summary of what changed and anything it couldn't change.
What it doesn't do: it doesn't delete the account, convert the mailbox to a shared mailbox, set an out-of-office reply, forward mail, hide the user from the address list, remove distribution lists or mail-enabled security groups, or wipe devices. Licences assigned through a group are removed only when the user leaves that group, which is why the action leaves licence-assigning groups alone unless remove_licences is on. See Variations.
Before you begin
- A Microsoft Graph credential for user administration, named
M365 Graph, with these scopes:User.ReadWrite.All User-PasswordProfile.ReadWrite.All User.RevokeSessions.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. See Automate Microsoft 365 with workflows. - These scopes come from Microsoft's documentation and haven't been tested with SuperIT. If a step fails with
Authorization_RequestDeniedor403, see If something goes wrong. - Your protected accounts. Edit
$protectedUpnsat the top of the script to list your emergency access (break-glass) accounts and any other account this action must never change. - 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 target any account. - The Engineer or Admin role to build the action in Workflows → Actions. Only admins can create actions in chat.
Build it with SuperIT
Create a workflow action called "Offboard Microsoft 365 user".
Runtime: PowerShell in the cloud sandbox. Credential: M365 Graph.
Inputs:
- user_upn (email, required) - the leaver's sign-in address
- confirm_leaver (boolean, required) - label "I confirm this person has left"
- remove_licences (boolean, optional)
It should refuse on-premises synced accounts, the credential's own account, a list
of protected accounts and admin role holders. Then block sign-in, set a random
password it doesn't print, and only then revoke sessions. Remove the user from
assigned-membership groups (but keep licence-assigning groups unless remove_licences is
true), remove direct licences if remove_licences is true, and print a summary. Keep going if one group fails, and list the failures.
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
| Name | Type | Required | Example |
|---|---|---|---|
user_upn |
Yes | [email protected] |
|
confirm_leaver |
boolean | Yes | on |
remove_licences |
boolean | No | on |
A required boolean must be switched on before the action can run, so confirm_leaver works as an acknowledgement.
Removing licences starts Microsoft's deletion clock
When remove_licences removes a licence that includes Exchange Online, Microsoft keeps the mailbox for about 30 days and then deletes its contents. OneDrive files are also subject to Microsoft's rules for unlicensed accounts. If you need the leaver's mail or files, convert the mailbox to a shared mailbox and move the files first, then run the action with remove_licences on. With remove_licences off, the action keeps the user in groups that assign a licence and lists them under "Needs attention", so the mailbox and OneDrive stay licensed.
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: lock a leaver out of Microsoft 365 (block sign-in, scramble the password, then revoke sessions),
# remove their groups and optionally their direct licences, then output what changed and what didn't.
$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'
# Emergency access (break-glass) and other accounts this action must never change. List yours.
$protectedUpns = @('[email protected]', '[email protected]')
function Invoke-UserOffboarding {
param([string]$UserUpn, [string]$ConfirmLeaver, [string]$RemoveLicences)
$upn = $UserUpn.Trim()
Assert-Inputs -Upn $upn -ConfirmLeaver $ConfirmLeaver
$user = Get-TargetUser -Upn $upn
Assert-CloudOnlyUser -User $user -Upn $upn
Assert-NotProtectedUser -User $user -Upn $upn
Disable-UserSignIn -UserId $user.id -Upn $upn
Set-UnknownPassword -UserId $user.id -Upn $upn
# Revoke last, so no new token can be issued between the steps from the old password.
Revoke-UserSessions -UserId $user.id -Upn $upn
$done = @('Sign-in blocked', 'Password scrambled', 'Sessions revoked')
# Leaving a licence-assigning group removes the licence, so keep those groups unless licences are meant to go.
$keepGroupIds = @()
if ($RemoveLicences -ne 'true') {
$keepGroupIds = @($user.licenseAssignmentStates | Where-Object { $_.assignedByGroup } | ForEach-Object assignedByGroup | Select-Object -Unique)
}
$groups = Remove-UserFromGroups -UserId $user.id -KeepGroupIds $keepGroupIds
$done += $groups.Done
if ($RemoveLicences -eq 'true') {
$removed = Remove-DirectLicences -User $user -Upn $upn
if ($removed -gt 0) { $done += "Removed $removed direct licence(s)" }
}
Write-OffboardingSummary -User $user -Upn $upn -Done $done -Problems $groups.Problems
# $script: because the runner returns the script-level $result, not a function's local copy.
$script:result = @{ user = $upn; done = $done; needs_attention = $groups.Problems }
}
function Assert-Inputs {
param([string]$Upn, [string]$ConfirmLeaver)
if ($Upn -notmatch '^[^@\s]+@[^@\s]+$') { throw "user_upn must be a sign-in address." }
if ($ConfirmLeaver -ne 'true') { throw "confirm_leaver must be switched on." }
}
function Get-TargetUser {
param([string]$Upn)
$select = 'id,displayName,userPrincipalName,onPremisesSyncEnabled,licenseAssignmentStates'
try {
Invoke-RestMethod -Uri "$baseUrl/users/$([uri]::EscapeDataString($Upn))?`$select=$select" -Headers $headers
} catch {
Write-Error "Failed to find user '$Upn': $_"
exit 1
}
}
function Assert-CloudOnlyUser {
param($User, [string]$Upn)
if ($User.onPremisesSyncEnabled) { throw "$Upn is synced from on-premises Active Directory. Disable it there." }
}
function Assert-NotProtectedUser {
param($User, [string]$Upn)
if ($protectedUpns -contains $Upn -or $protectedUpns -contains $User.userPrincipalName) {
throw "$Upn is on this action's protected list. Nothing was changed."
}
try {
$me = Invoke-RestMethod -Uri "$baseUrl/me?`$select=id" -Headers $headers
$memberOf = @(Get-GraphCollection -Uri "$baseUrl/users/$($User.id)/transitiveMemberOf?`$select=id,isAssignableToRole")
} catch {
Write-Error "Failed to check whether '$Upn' is a protected account: $_"
exit 1
}
if ($me.id -eq $User.id) { throw "$Upn is the account behind this action's credential. Nothing was changed." }
# Without a group-read scope Graph returns groups with only id and type, which would hide a role held through a role-assignable group.
$unreadable = @($memberOf | Where-Object { $_.'@odata.type' -eq '#microsoft.graph.group' -and -not $_.PSObject.Properties['isAssignableToRole'] })
if ($unreadable.Count -gt 0) {
throw "Can't read $Upn's groups to check for admin roles. Add GroupMember.Read.All to the credential and re-authorize it. Nothing was changed."
}
$adminRoles = @($memberOf | Where-Object { $_.'@odata.type' -eq '#microsoft.graph.directoryRole' -or $_.isAssignableToRole })
if ($adminRoles.Count -gt 0) {
throw "$Upn holds a Microsoft Entra admin role. Offboard admin accounts in the Microsoft Entra admin center. Nothing was changed."
}
}
function Disable-UserSignIn {
param([string]$UserId, [string]$Upn)
try {
Invoke-RestMethod -Method Patch -Uri "$baseUrl/users/$UserId" -Headers $headers -ContentType 'application/json' `
-Body (@{ accountEnabled = $false } | ConvertTo-Json) | Out-Null
} catch {
Write-Error "Failed to block sign-in for '$Upn': $_"
exit 1
}
}
function Set-UnknownPassword {
param([string]$UserId, [string]$Upn)
$password = [Convert]::ToBase64String([Security.Cryptography.RandomNumberGenerator]::GetBytes(24)) + 'a1!'
$body = @{ passwordProfile = @{ password = $password; forceChangePasswordNextSignIn = $true } } | ConvertTo-Json
try {
Invoke-RestMethod -Method Patch -Uri "$baseUrl/users/$UserId" -Headers $headers `
-ContentType 'application/json' -Body $body | Out-Null
} catch {
Write-Error "Failed to scramble the password for '$Upn': $_"
exit 1
}
}
function Revoke-UserSessions {
param([string]$UserId, [string]$Upn)
try {
Invoke-RestMethod -Method Post -Uri "$baseUrl/users/$UserId/revokeSignInSessions" -Headers $headers | Out-Null
} catch {
Write-Error "Failed to revoke sessions for '$Upn': $_"
exit 1
}
}
function Remove-UserFromGroups {
param([string]$UserId, [string[]]$KeepGroupIds = @())
$done = @(); $problems = @()
try {
$memberOf = Get-GraphCollection -Uri "$baseUrl/users/$UserId/memberOf?`$select=id,displayName,groupTypes,mailEnabled"
} catch {
Write-Error "Failed to list groups for user '$UserId': $_"
exit 1
}
foreach ($g in $memberOf | Where-Object { $_.'@odata.type' -eq '#microsoft.graph.group' }) {
if ($KeepGroupIds -contains $g.id) { $problems += "$($g.displayName): assigns a licence, kept (remove_licences is off)"; continue }
if ($g.groupTypes -contains 'DynamicMembership') { $problems += "$($g.displayName): dynamic group, skipped"; continue }
if ($g.mailEnabled -and $g.groupTypes -notcontains 'Unified') {
$problems += "$($g.displayName): mail-enabled, remove in Exchange"; continue
}
try {
Invoke-RestMethod -Method Delete -Uri "$baseUrl/groups/$($g.id)/members/$UserId/`$ref" -Headers $headers | Out-Null
$done += "Removed from $($g.displayName)"
} catch {
$problems += "$($g.displayName): $($_.ErrorDetails.Message ?? $_.Exception.Message)"
}
}
[pscustomobject]@{ Done = $done; Problems = $problems }
}
function Remove-DirectLicences {
param($User, [string]$Upn)
$direct = @($User.licenseAssignmentStates | Where-Object { -not $_.assignedByGroup } | ForEach-Object skuId)
if ($direct.Count -eq 0) { return 0 }
$body = @{ addLicenses = @(); removeLicenses = $direct } | ConvertTo-Json -Depth 3
try {
Invoke-RestMethod -Method Post -Uri "$baseUrl/users/$($User.id)/assignLicense" -Headers $headers `
-ContentType 'application/json' -Body $body | Out-Null
} catch {
Write-Error "Failed to remove licences for '$Upn': $_"
exit 1
}
$direct.Count
}
function Get-GraphCollection {
param([string]$Uri)
$items = @()
while ($Uri) {
$page = Invoke-RestMethod -Uri $Uri -Headers $headers
$items += $page.value
$Uri = $page.'@odata.nextLink'
}
$items
}
function Write-OffboardingSummary {
param($User, [string]$Upn, [string[]]$Done, [string[]]$Problems)
Write-Output "=== USER OFFBOARDED ==="
Write-Output "User : $($User.displayName) ($Upn)"
$Done | ForEach-Object { Write-Output "Done : $_" }
$Problems | ForEach-Object { Write-Output "Needs attention : $_" }
}
Invoke-UserOffboarding -UserUpn $ctx.inputs.user_upn -ConfirmLeaver $ctx.inputs.confirm_leaver `
-RemoveLicences $ctx.inputs.remove_licences
Test it
Test runs for real
Test skips authorization and approval, uses the real M365 Graph credential, and really blocks the account. Only test with a test user you created for this.
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.
- Create a test user in Microsoft 365 with a licence and a couple of groups. For the licence-keeping test in step 6, give the user a second licence through a licence-assigning group, and add them to one ordinary group.
- On the action, select Test and enter the test user's address. Switch on
confirm_leaverandremove_licences. - 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 summary, and check the test user in the Microsoft 365 admin center.
- Test again with the credential's own admin account, and check the run is refused before anything changes.
- Test with a fresh test user set up as in step 1, with
confirm_leaveron andremove_licencesoff. Check that the user stays in the licence-assigning group and keeps its licence, that the ordinary group is removed, and that the kept group appears under "Needs attention".
Use it
Publish the action. Engineers ask SuperIT, for example "Offboard [email protected], she left today, and take her licences back". SuperIT asks for the confirmation input, the run waits for an admin to approve, and SuperIT reports the summary. If the request came from a ticket, SuperIT adds a note to the ticket when the run is approved and when it completes, with a link to the run; the summary itself stays in the run's details.
Before or after the run, engineers can ask SuperIT to check things it can read, for example "Does Jane have any inbox rules or mail forwarding?" (see What SuperIT can do in Microsoft 365).
For MSPs, publish to managed teams from the Publish dialog; an engineer or admin in each team authorizes its own M365 Graph credential.
Variations
- Set an out-of-office reply and forward mail to the manager with Set an out-of-office reply and Set up mail forwarding.
- Convert the mailbox to shared with Exchange Online (
Set-Mailbox -Type Shared) before removing the licence, so the mail is kept. This needs theM365 Exchangecredential, so build it as a separate action. - Sign the leaver out of their Windows computers with a separate device action that runs through the SuperIT RMM agent.
- Remove the leaver's MFA methods too. See Reset MFA or issue a Temporary Access Pass.