Joiner-Mover-Leaver is the unglamorous backbone of identity governance, and most organizations still run it on a pile of PowerShell scheduled tasks, a runbook nobody trusts, and a Friday-afternoon “did we actually disable that leaver?” Slack thread. Entra ID Lifecycle Workflows (LCW) replace that with a declarative, time-triggered engine that fires built-in tasks on employeeHireDate and employeeLeaveDateTime, and hands off to Logic Apps for anything Microsoft does not ship out of the box. This guide builds all three flows end to end, wires custom task extensions, and covers the operational reality of scheduling, back-dating, and failure handling.
Lifecycle Workflows require Microsoft Entra ID Governance licensing (or the Entra Suite). It is not part of P1/P2. Budget for that before you design anything.
1. The Lifecycle Workflows execution model
Three concepts drive everything. Get them straight and the rest is configuration.
- Trigger. What starts evaluation. The big two are
timeBasedAttributeTrigger(a number of days offset from a date attribute likeemployeeHireDateoremployeeLeaveDateTime) andattributeChanges(the Mover trigger, fired when a watched attribute changes). On-demand runs bypass the trigger entirely. - Execution conditions. A scope (which users this workflow applies to, expressed as a rule over user attributes) plus the trigger. A user must satisfy the scope and hit the trigger window for tasks to run.
- Tasks. An ordered collection of
taskDefinitionIdreferences that execute against each in-scope user. Built-in tasks have stable GUIDs; custom logic runs through acustomTaskExtension.
The engine evaluates time-based workflows on a recurring schedule (default every 3 hours, configurable). When a user enters the trigger window, a run is created and tasks execute in order. Critically, the trigger fires relative to the attribute value, not the moment you create the workflow. A workflow that fires “7 days before employeeHireDate” will pick up anyone whose hire date is within the next 7 days on the next schedule pass.
One workflow, one trigger, one direction. You will build separate workflows for joiner, mover, and leaver. Do not try to cram them into one. The scope rules and triggers are mutually exclusive by design.
All of this lives under the identityGovernance/lifecycleWorkflows Graph namespace. Console (Entra admin center > Identity Governance > Lifecycle Workflows) is fine for exploration, but treat Graph as the source of truth so workflows are reviewable and reproducible.
Connect with the right scopes:
Connect-MgGraph -Scopes "LifecycleWorkflows.ReadWrite.All", `
"Application.ReadWrite.All", "Organization.Read.All"
# List the built-in task definitions and their GUIDs
Get-MgIdentityGovernanceLifecycleWorkflowTaskDefinition |
Select-Object Id, DisplayName, Category | Format-Table -AutoSize
That TaskDefinition list is the catalog you build from. The GUIDs are stable across tenants, so the ones below are reusable.
2. Joiner workflow: TAP, welcome email, groups and licenses
A joiner needs to be productive on day one without a human in the loop. The canonical pre-hire flow generates a Temporary Access Pass (TAP) so the new hire can register passwordless credentials, sends a welcome message, and assigns baseline groups and licenses.
Build it as a timeBasedAttributeTrigger offset from employeeHireDate. A negative offsetInDays means before the date.
$params = @{
category = "joiner"
displayName = "Onboard pre-hire employee"
description = "TAP, welcome email, and baseline access 7 days before start"
isEnabled = $true
isSchedulingEnabled = $true
executionConditions = @{
"@odata.type" = "#microsoft.graph.workflowExecutionConditions"
scope = @{
"@odata.type" = "#microsoft.graph.identityGovernance.ruleBasedSubjectSet"
# Only cloud-mastered employees in scope
rule = "(department -eq 'Engineering') and (userType -eq 'Member')"
}
trigger = @{
"@odata.type" = "#microsoft.graph.identityGovernance.timeBasedAttributeTrigger"
timeBasedAttribute = "employeeHireDate"
offsetInDays = -7
}
}
tasks = @(
@{
isEnabled = $true
displayName = "Generate Temporary Access Pass"
taskDefinitionId = "1b555e50-7f65-41d5-b514-5894a026d10d"
arguments = @(
@{ name = "tapLifetimeMinutes"; value = "480" }
@{ name = "tapIsUsableOnce"; value = "false" }
)
},
@{
isEnabled = $true
displayName = "Send welcome email"
taskDefinitionId = "70b29d51-b59a-4773-9280-8841dfd3f2ea"
}
)
}
New-MgIdentityGovernanceLifecycleWorkflow -BodyParameter $params
A few things that bite people here:
- The TAP task requires the Temporary Access Pass authentication method policy to be enabled tenant-wide, and the
tapLifetimeMinutesyou request must fall inside the min/max the policy allows. If it does not, the task fails at run time, not at creation. - The TAP is delivered via the welcome email only if you order the email task after the TAP task and the email template references it. By default the welcome email does not contain the TAP. Most teams instead route the TAP to the hiring manager via a custom extension (covered in section 5) because emailing a credential to an account that does not yet have a mailbox is pointless.
- Group and license assignment are not native LCW tasks. There is a built-in
Add user to groupstask (22085229-5809-45e8-97fd-270d28d66910) andAdd user to teams, but license assignment is best handled by group-based licensing: the LCW task adds the user to a licensing group, and Entra assigns the license. Do not try to assign licenses directly from a workflow.
Reference the welcome-email and add-to-groups tasks by their built-in IDs:
| Task | taskDefinitionId | Category |
|---|---|---|
| Generate TAP | 1b555e50-7f65-41d5-b514-5894a026d10d |
joiner |
| Send welcome email | 70b29d51-b59a-4773-9280-8841dfd3f2ea |
joiner |
| Add user to groups | 22085229-5809-45e8-97fd-270d28d66910 |
joiner, mover, leaver |
| Add user to teams | e440ed8d-25a1-4618-84ce-091ed5be5594 |
joiner, mover |
3. Mover workflow: re-evaluating access on transfer
Movers are where governance programs leak. Someone transfers from Sales to Finance, keeps their old CRM access, and accumulates entitlements until an audit finds it. The Mover trigger watches an attribute and fires when it changes.
$moverParams = @{
category = "mover"
displayName = "Re-evaluate access on department transfer"
isEnabled = $true
isSchedulingEnabled = $true
executionConditions = @{
"@odata.type" = "#microsoft.graph.workflowExecutionConditions"
scope = @{
"@odata.type" = "#microsoft.graph.identityGovernance.ruleBasedSubjectSet"
rule = "(userType -eq 'Member')"
}
trigger = @{
"@odata.type" = "#microsoft.graph.identityGovernance.attributeChangeTrigger"
triggerAttributes = @( @{ name = "department" } )
}
}
tasks = @(
@{
isEnabled = $true
displayName = "Send email to manager before transfer"
taskDefinitionId = "aab41899-9972-422a-9d97-f626014578b7"
},
@{
isEnabled = $true
displayName = "Run a custom task extension"
taskDefinitionId = "4262b724-8dba-4fad-afc3-43fcbb497a0e"
arguments = @(
@{ name = "customTaskExtensionId"; value = "<extension-guid>" }
)
}
)
}
New-MgIdentityGovernanceLifecycleWorkflow -BodyParameter $moverParams
Two design notes that matter for Mover:
- The
attributeChangeTriggeronly fires for attributes that flow into Entra. Ifdepartmentis mastered in your HR system and synced via Entra Cloud Sync or HR-driven provisioning, the change is detected after the sync writes it. If it is mastered on-prem and synced by Entra Connect, the same applies but on the Connect sync cycle. The workflow does not poll HR directly. - LCW intentionally does not ship a “remove all access” Mover task. Transfers are a re-entitlement problem: the clean pattern is to remove the user from the old department’s dynamic/assigned groups (use
Remove user from groups, ID1953a66c-751c-45e5-8bfe-01462c70da3f) and let access packages or dynamic group membership grant the new role’s access. Lifecycle Workflows orchestrate the trigger and notification; access packages do the entitlement.
4. Leaver workflow: disable, revoke sessions, strip membership
The Leaver workflow is the one auditors actually test. Fire it on employeeLeaveDateTime with a small offset so containment is immediate at termination.
$leaverParams = @{
category = "leaver"
displayName = "Real-time termination - contain account"
isEnabled = $true
isSchedulingEnabled = $true
executionConditions = @{
"@odata.type" = "#microsoft.graph.workflowExecutionConditions"
scope = @{
"@odata.type" = "#microsoft.graph.identityGovernance.ruleBasedSubjectSet"
rule = "(userType -eq 'Member')"
}
trigger = @{
"@odata.type" = "#microsoft.graph.identityGovernance.timeBasedAttributeTrigger"
timeBasedAttribute = "employeeLeaveDateTime"
offsetInDays = 0
}
}
tasks = @(
@{ isEnabled = $true; displayName = "Disable user account"
taskDefinitionId = "1dfdfcc7-52fa-4c2e-bf3a-e3919cc12950" },
@{ isEnabled = $true; displayName = "Revoke all refresh tokens"
taskDefinitionId = "8d18588d-9ad3-4c0d-8511-a8aff0a51a06" },
@{ isEnabled = $true; displayName = "Remove user from all groups"
taskDefinitionId = "b3a31406-2a15-4c9a-b25b-a658fa5f07fc" },
@{ isEnabled = $true; displayName = "Remove user from all Teams"
taskDefinitionId = "81f7b200-2816-4b3b-8c5d-dc556f07b024" },
@{ isEnabled = $true; displayName = "Remove all license assignments"
taskDefinitionId = "8fa48000-8061-4f6b-9b9c-eb7c5ddf3d0a" }
)
}
New-MgIdentityGovernanceLifecycleWorkflow -BodyParameter $leaverParams
Order matters and is enforced as written. Disable first so the account cannot authenticate, then revoke refresh tokens so existing sessions die at the next access-token refresh (revocation invalidates refresh tokens; access tokens live until expiry, typically up to an hour — Continuous Access Evaluation closes that gap for CAE-aware apps). Stripping group memberships before removing licenses prevents a brief window where the user is still entitled.
What LCW deliberately does not do: delete the account, convert the mailbox to shared, or revoke on-prem access. Those are post-offboarding (commonly a second workflow at employeeLeaveDateTime + 30) or custom-extension territory. Use the built-in Delete user task (8d18588d-style GUID, category leaver) only in a delayed cleanup workflow, never the same-day containment one.
5. Custom task extensions: Logic Apps for HR, ITSM, on-prem
Anything Microsoft does not ship — opening a ServiceNow ticket, telling the on-prem HR system the user is gone, triggering an MIM/PowerShell deprovisioning job — runs through a custom task extension that calls an Azure Logic App. The extension is a Graph object that points at a Logic App workflow callback URL and declares whether the callout is fire-and-forget or response-bound (LCW waits for the Logic App to call back before marking the task complete).
The Logic App must start with a “When an HTTP request is received” trigger. Entra calls it with a payload describing the task, subject, and a callback URI. For response-bound extensions, your Logic App calls that URI to report success/failure.
Create the extension and link the Logic App. The cleanest way is from the Entra admin center (it provisions the system-assigned identity and authorization correctly), but the Graph shape is:
POST https://graph.microsoft.com/v1.0/identityGovernance/lifecycleWorkflows/customTaskExtensions
Content-Type: application/json
{
"displayName": "Open ITSM offboarding ticket",
"endpointConfiguration": {
"@odata.type": "#microsoft.graph.logicAppTriggerEndpointConfiguration",
"subscriptionId": "<sub-guid>",
"resourceGroupName": "rg-identity-automation",
"logicAppWorkflowName": "la-offboarding-itsm",
"url": "https://prod-00.eastus.logic.azure.com:443/workflows/.../triggers/manual/paths/invoke?..."
},
"authenticationConfiguration": {
"@odata.type": "#microsoft.graph.azureAdTokenAuthentication",
"resourceId": "<app-id-uri-or-client-id-of-the-logic-app-app-registration>"
},
"callbackConfiguration": {
"@odata.type": "#microsoft.graph.identityGovernance.customTaskExtensionCallbackConfiguration",
"timeoutDuration": "PT1H",
"authorizedApps": [ { "id": "<lcw-service-principal-app-id>" } ]
}
}
The callbackConfiguration is what makes it response-bound. With timeoutDuration set (ISO 8601 duration, max PT3H — three hours), LCW pauses the task until the Logic App posts back to the callback URI. Omit callbackConfiguration entirely for fire-and-forget.
A minimal response-bound Logic App that opens a ticket and reports back looks like this in its workflow definition:
{
"definition": {
"triggers": {
"manual": {
"type": "Request",
"kind": "Http",
"inputs": {
"schema": {
"type": "object",
"properties": {
"subject": { "type": "object" },
"callbackUriPath": { "type": "string" },
"taskProcessingResult": { "type": "object" }
}
}
}
}
},
"actions": {
"Create_ServiceNow_incident": {
"type": "Http",
"inputs": {
"method": "POST",
"uri": "https://acme.service-now.com/api/now/table/incident",
"authentication": { "type": "ManagedServiceIdentity" },
"body": {
"short_description": "@concat('Offboard ', triggerBody()?['subject']?['displayName'])"
}
}
},
"Resume_Lifecycle_Workflow": {
"runAfter": { "Create_ServiceNow_incident": ["Succeeded"] },
"type": "Http",
"inputs": {
"method": "POST",
"uri": "https://graph.microsoft.com/v1.0@{triggerBody()?['callbackUriPath']}",
"authentication": { "type": "ManagedServiceIdentity", "audience": "https://graph.microsoft.com" },
"body": {
"source": "la-offboarding-itsm",
"type": "Microsoft.Graph.LifecycleWorkflows.CustomTaskExtensionCalloutResponse",
"data": {
"@@odata.type": "microsoft.graph.identityGovernance.customTaskExtensionCalloutData",
"operationStatus": "Completed"
}
}
}
}
}
}
}
Reference the extension from any task using the “Run a custom task extension” built-in task (4262b724-8dba-4fad-afc3-43fcbb497a0e), passing the customTaskExtensionId argument as shown in the Mover example. This single mechanism covers HR write-back, ITSM, and on-prem deprovisioning — you just point different extensions at different Logic Apps.
6. Scheduling, on-demand runs, and back-dating
By default the engine evaluates scheduled workflows every 3 hours. Tune the tenant-wide interval (1 to 24 hours) when you need tighter SLAs on terminations:
# Tighten the evaluation cadence to hourly
Update-MgIdentityGovernanceLifecycleWorkflowSetting -BodyParameter @{
workflowScheduleIntervalInHours = 1
}
For ad-hoc needs — a same-day emergency termination, or testing — run a workflow on demand against specific users. This bypasses the scope and trigger entirely and executes immediately:
$body = @{
subjects = @(
@{ id = "<userObjectId-1>" },
@{ id = "<userObjectId-2>" }
)
}
Invoke-MgActivateIdentityGovernanceLifecycleWorkflow `
-WorkflowId "<workflow-id>" -BodyParameter $body
Back-dating is the subtlety that trips up every first rollout. When you enable a time-based workflow, the engine will pick up users who are already inside the trigger window. Enable a “disable account on employeeLeaveDateTime” workflow today and every user whose leave date was in the past — and who is still enabled — becomes in scope on the next pass. That can disable hundreds of accounts at once. Two guardrails:
- Keep
isSchedulingEnabled = $falsewhile you build and test, run on-demand against a pilot, and only flip scheduling on once the scope is proven. - Tighten the scope rule so historical/edge users are excluded, then widen it deliberately.
There is no “ignore anyone whose date is more than N days in the past” knob — the protection is scope discipline plus a controlled enablement.
7. Monitoring, failures, and retries
Every execution produces a run, and every run has per-user, per-task results. This is your audit trail and your alerting surface.
# Summary of recent runs for a workflow
Get-MgIdentityGovernanceLifecycleWorkflowRun -LifecycleWorkflowId "<workflow-id>" `
-Top 20 | Select-Object Id, ProcessingStatus, TotalUsersCount, FailedUsersCount
# Drill into the per-user task results for one run
Get-MgIdentityGovernanceLifecycleWorkflowRunUserProcessingResult `
-LifecycleWorkflowId "<workflow-id>" -RunId "<run-id>" |
Select-Object Id, ProcessingStatus, FailedTasksCount
Failure-handling reality you must design around:
- Built-in tasks retry automatically. Each task is attempted on subsequent schedule passes if it fails, with a bounded number of retries before the task is marked failed. You do not configure per-task retry counts.
- A failed task does not roll back prior tasks. If “remove from groups” fails after “disable account” succeeded, the account stays disabled. That is correct for leaver containment but means you must monitor for partial completion.
- Custom extension timeouts are governed by
timeoutDuration. If the Logic App never calls back within the window, the task fails — so make response-bound extensions robust and idempotent, because a retry will call the Logic App again.
For alerting, stream the runs to a queryable store. LCW emits to the Audit logs, which you forward to a Log Analytics workspace via diagnostic settings, then alert on failures:
AuditLogs
| where Category == "WorkflowManagement"
| where OperationName has "task" and Result == "failure"
| extend wf = tostring(TargetResources[0].displayName)
| summarize failures = count() by wf, bin(TimeGenerated, 1h)
| where failures > 0
Wire that query to an Azure Monitor alert so a failed leaver containment pages someone, rather than surfacing in a quarterly access review.
8. Source-of-truth integration and attribute flow
LCW is only as good as the attributes it triggers on. The two it cares about most — employeeHireDate and employeeLeaveDateTime — are usually not populated by default. The supported pattern is inbound HR-driven provisioning: Workday, SuccessFactors, or SAP HR (or a generic SCIM/API-driven inbound app) writes these attributes into Entra, and LCW triggers off them.
The attribute flow is HR system -> Entra provisioning -> employeeHireDate / employeeLeaveDateTime on the user object -> LCW trigger evaluation. Verify the values are landing in the correct format (UTC, ISO 8601) before you trust any trigger:
Get-MgUser -UserId "ada@contoso.com" `
-Property "displayName,employeeHireDate,employeeLeaveDateTime,department" |
Select-Object DisplayName, EmployeeHireDate, EmployeeLeaveDateTime, Department
Three integration rules that keep this clean:
- One writer per attribute. If both HR provisioning and an admin script can set
employeeLeaveDateTime, you will get fights. Let HR be the sole source. - Mind the master. For hybrid users mastered on-prem, these attributes must flow up through Entra Connect/Cloud Sync. LCW cannot trigger on a value that never reaches the cloud object.
- Sequence inbound before LCW. The HR sync must complete before the LCW schedule pass for same-day actions to fire on time. With a 1-hour HR sync and a 1-hour LCW interval, worst case is roughly two hours from HR change to action.
Enterprise scenario
A global engineering firm (around 14,000 employees) ran offboarding through a nightly PowerShell job triggered off a CSV export from Workday. The gap that hurt: terminations entered in Workday during the business day did not act until the 2 a.m. batch, leaving up to ~18 hours where a fired engineer kept full access to source control and cloud consoles. Security flagged it after a contentious departure.
They moved to Lifecycle Workflows with Workday inbound provisioning writing employeeLeaveDateTime. The constraint surfaced immediately: the default 3-hour LCW interval still left a multi-hour window, and the SOC wanted termination containment inside one hour of the HR entry. They could not simply drop the interval to 1 hour because they also needed an on-prem AD disable and a CrowdStrike host-isolation step that LCW does not ship.
The solution combined three pieces. They set workflowScheduleIntervalInHours = 1 and dropped the Workday inbound sync to a matching cadence. The same-day leaver workflow ran the built-in disable + revoke-tokens tasks for immediate cloud containment, then a response-bound custom task extension called a Logic App that hit the on-prem AD via an Azure Automation Hybrid Runbook Worker and triggered CrowdStrike isolation through its API. For true zero-tolerance cases, the SOC was given an on-demand activation runbook so they could fire the leaver workflow against a specific user the instant HR confirmed, without waiting for the schedule:
# SOC-invoked immediate containment, bypasses schedule and scope
Invoke-MgActivateIdentityGovernanceLifecycleWorkflow `
-WorkflowId $leaverWorkflowId `
-BodyParameter @{ subjects = @(@{ id = $terminatedUserId }) }
The measurable outcome: routine terminations contained within an hour, and emergency ones within minutes via the on-demand path — replacing an 18-hour batch window with a self-documenting, auditable run history the SOC could query in Log Analytics.
Verify
Before you call any workflow production-ready:
# 1. Confirm the workflow exists and its trigger/scope are correct
Get-MgIdentityGovernanceLifecycleWorkflow -LifecycleWorkflowId "<workflow-id>" |
Select-Object DisplayName, Category, IsEnabled, IsSchedulingEnabled
# 2. Dry-run against a pilot user with on-demand activation, scheduling still OFF
Invoke-MgActivateIdentityGovernanceLifecycleWorkflow `
-WorkflowId "<workflow-id>" `
-BodyParameter @{ subjects = @(@{ id = "<pilotUserId>" }) }
# 3. Inspect the resulting run and per-task outcomes
Get-MgIdentityGovernanceLifecycleWorkflowRun -LifecycleWorkflowId "<workflow-id>" -Top 1 |
Select-Object Id, ProcessingStatus, SuccessfulUsersCount, FailedUsersCount
A healthy pilot shows ProcessingStatus = completed, the expected user count, zero failures, and — for a leaver — the pilot account actually disabled and stripped of group membership when you check it directly.