Identity Azure

Automating Joiner-Mover-Leaver with Entra ID Lifecycle Workflows and Custom Extensions

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.

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:

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:

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:

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:

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:

  1. 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.
  2. 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.
  3. 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.

Checklist

Entra IDIdentity GovernanceLifecycle WorkflowsJMLAutomation

Comments

Keep Reading