Most tenants treat identity governance as a portal you visit during an audit. That is how you end up with a contractor who still holds Owner on three production subscriptions, a security group whose membership nobody can explain, and 400 guest accounts that last signed in 18 months ago. Access creep is not a one-time cleanup problem - it is a rate problem. Grants happen continuously through tickets and group adds; removal happens almost never. Governance at scale means building the machinery that revokes at the same rate access is granted: access packages for time-bound self-service, lifecycle workflows for joiner-mover-leaver automation, and access reviews for periodic re-attestation. This guide wires all three together with Microsoft Graph and ties them back to PIM and audit evidence.
Everything here requires Microsoft Entra ID Governance (or the Entra Suite) licensing for the principals being governed - entitlement management, lifecycle workflows, and recurring reviews are not in the base P2 SKU. Check licensing before you design.
1. The three governance gaps you are closing
Before any config, name the failure modes you are designing against. Every control below maps to one.
| Gap | What it looks like | Control |
|---|---|---|
| Access creep | Permissions accumulate, never shrink | Expiring access packages, recurring reviews |
| Orphaned access | Leaver/mover keeps old entitlements | Lifecycle workflows on the JML trigger |
| Standing privilege | Permanent admin on high-value scopes | PIM eligible assignments + reviews |
| Audit blindness | Cannot prove who approved what, when | Catalog policies + review decision logs |
The mental shift: stop asking “who has access?” Start asking “who has access, granted by whom, justified how, expiring when, and last reviewed on what date?” If you cannot answer all five from a query, you do not have governance - you have a list.
Connect with the Identity Governance module and the least-privilege scopes you actually need:
Install-Module Microsoft.Graph.Identity.Governance -Scope CurrentUser
Connect-MgGraph -Scopes @(
"EntitlementManagement.ReadWrite.All",
"AccessReview.ReadWrite.All",
"LifecycleWorkflows.ReadWrite.All",
"RoleManagement.ReadWrite.Directory"
)
2. Design catalogs, access packages, and policies
Entitlement management has a deliberate hierarchy. A catalog is a container of governed resources (groups, apps, SharePoint sites) plus a delegation boundary - resource owners manage their catalog without tenant-wide admin. An access package bundles specific roles on those resources into one requestable unit. A policy on the package defines who can request it, who approves, and how long the grant lasts.
The biggest design error is one giant catalog owned by central IT - that recreates the ticket bottleneck you are trying to kill. Build catalogs per business domain (Finance, Engineering, Data Platform) and delegate ownership to those teams.
# Catalog scoped to a business domain, delegable to its owners
$catalog = New-MgEntitlementManagementCatalog -BodyParameter @{
displayName = "Data Platform"
description = "Governed access for the data platform domain"
isExternallyVisible = $false # internal only; flip to $true for B2B sharing
}
# Add a Microsoft Entra group as a governed resource in the catalog
New-MgEntitlementManagementResourceRequest -BodyParameter @{
requestType = "AdminAdd"
catalog = @{ id = $catalog.Id }
resource = @{
originId = "f3f9a4d2-0000-0000-0000-000000000001" # group objectId
originSystem = "AadGroup"
}
}
Now create the package and bind a request policy. Note the policy is where governance lives - expiration, requestApprovalSettings, and requestorSettings are not optional decorations.
$ap = New-MgEntitlementManagementAccessPackage -BodyParameter @{
displayName = "Data Platform - Analyst (read)"
description = "Read access to the data lake and BI workspace"
catalog = @{ id = $catalog.Id }
}
New-MgEntitlementManagementAssignmentPolicy -BodyParameter @{
displayName = "Analyst - 180 day, manager approval"
accessPackage = @{ id = $ap.Id }
# Who may request: all members (scope can be narrowed to specific groups)
requestorSettings = @{
enableTargetsToSelfAddAccess = $true
enableTargetsToSelfRemoveAccess = $true
allowCustomAssignmentSchedule = $true
onBehalfRequestorsAllowed = $false
}
# Approval: single-stage, the requestor's manager
requestApprovalSettings = @{
isApprovalRequiredForAdd = $true
stages = @(
@{
durationBeforeAutomaticDenial = "P7D"
isApproverJustificationRequired = $true
primaryApprovers = @(
@{ "@odata.type" = "#microsoft.graph.requestorManager"; managerLevel = 1 }
)
}
)
}
# Time-bound: access expires, forcing re-request
expiration = @{ type = "afterDuration"; duration = "P180D" }
}
The expiration block is the antidote to access creep. With afterDuration set to P180D, every grant self-destructs in 180 days; if the analyst still needs it, they re-request and a manager re-approves. No standing analyst access exists by default.
3. Approvals, separation of duties, and expiration
Two policy capabilities turn a request form into a control.
Multi-stage approval chains independent approvers - use it when the resource owner and the requestor’s manager must both sign off. Add a second object to the stages array with its own primaryApprovers.
Separation of duties (SoD) blocks toxic combinations at request time, before the grant exists. If holding both “Vendor Onboarding” and “Payment Approval” enables fraud, mark them incompatible. A user assigned to one cannot even request the other.
# Mark another access package as incompatible with this one (SoD)
New-MgEntitlementManagementAccessPackageIncompatibleAccessPackageByRef `
-AccessPackageId $paymentApprovalPkgId `
-BodyParameter @{
"@odata.id" = "https://graph.microsoft.com/v1.0/identityGovernance/entitlementManagement/accessPackages/$vendorOnboardingPkgId"
}
# You can also make membership of a security group incompatible
New-MgEntitlementManagementAccessPackageIncompatibleGroupByRef `
-AccessPackageId $paymentApprovalPkgId `
-BodyParameter @{
"@odata.id" = "https://graph.microsoft.com/v1.0/groups/$contractorsGroupId"
}
SoD enforced here is far stronger than a detective control in Sentinel: the conflicting access never gets granted, so there is no window of exposure to detect - the difference between a guardrail and an alarm.
4. Automate joiner-mover-leaver with lifecycle workflows
Access packages handle requested access. Lifecycle workflows handle automatic access tied to employment events - the joiner who needs default groups on day one, and far more importantly, the leaver who must lose everything the moment they depart.
A workflow has a trigger (a time offset relative to an attribute like employeeHireDate or employeeLeaveDateTime), an execution scope (a rule selecting which users it applies to), and an ordered list of tasks. The tasks are referenced by stable taskDefinitionId GUIDs.
Here is a leaver workflow that fires on the employee leave date and de-provisions cleanly. These GUIDs are the documented built-in task definitions:
New-MgIdentityGovernanceLifecycleWorkflow -BodyParameter @{
displayName = "Leaver - real-time offboard"
description = "Disable, strip groups/licenses, revoke tokens on departure"
category = "leaver"
isEnabled = $true
executionConditions = @{
"@odata.type" = "#microsoft.graph.identityGovernance.triggerAndScopeBasedConditions"
scope = @{
"@odata.type" = "#microsoft.graph.identityGovernance.ruleBasedSubjectSet"
rule = "(department -eq 'Engineering')"
}
trigger = @{
"@odata.type" = "#microsoft.graph.identityGovernance.timeBasedAttributeTrigger"
timeBasedAttribute = "employeeLeaveDateTime"
offsetInDays = 0
}
}
tasks = @(
@{ category = "leaver"; displayName = "Disable user account"; isEnabled = $true
taskDefinitionId = "1dfdfcc7-52fa-4c2e-bf3a-e3919cc12950"; arguments = @() }
@{ category = "leaver"; displayName = "Remove user from all groups"; isEnabled = $true
taskDefinitionId = "b3a31406-2a15-4c9a-b25b-a658fa5f07fc"; arguments = @() }
@{ category = "leaver"; displayName = "Remove all licenses for user"; isEnabled = $true
taskDefinitionId = "8fa97d28-3e52-4985-b3a9-a1126f9b8b4e"; arguments = @() }
@{ category = "leaver"; displayName = "Revoke all refresh tokens for user"; isEnabled = $true
taskDefinitionId = "509589a4-0466-4471-829e-49c5e502bdee"; arguments = @() }
)
}
The joiner side uses different task IDs - Send welcome email to new hire (70b29d51-b59a-4773-9280-8841dfd3f2ea) and Add user to groups (22085229-5809-45e8-97fd-270d28d66910), triggered at offsetInDays = -7 against employeeHireDate so accounts are ready before day one. Disable (1dfdfcc7-...) and Enable user account (6fc52c9d-398b-4305-9763-15f42c1676fc) cover suspend/return.
Custom task extensions for systems Graph cannot reach
Built-in tasks cover Entra-native objects. For anything external - de-provisioning a Salesforce seat, releasing a phone number, archiving a mailbox via a third party - use the Run a Custom Task Extension task (d79d1fcc-16be-490c-a865-f4533b1639ee), which calls an Azure Logic App. Two execution modes matter:
- Launch and continue - fire the Logic App, do not wait. For fire-and-forget side effects.
- Launch and wait - block the workflow on the Logic App’s callback (up to the configured timeout). Use when downstream success gates later tasks, e.g. confirm the external system disabled the account before you delete the Entra object.
You can register up to 100 custom task extensions per tenant. Treat the Logic App as the integration boundary and keep the workflow declarative.
5. Recurring access reviews for groups, apps, and roles
Lifecycle workflows catch the clean leaver. They do not catch the mover who changed teams but kept their old group, or the access granted manually outside any package. That residue is what access reviews re-attest on a schedule.
A review is an accessReviewScheduleDefinition: a scope (what is reviewed), reviewers (who decides), and settings (recurrence, duration, and what happens to non-responses). The two settings that make a review a control rather than a survey are autoApplyDecisionsEnabled and defaultDecision - without auto-apply, “remove” decisions sit in a report nobody actions.
$params = @{
displayName = "Quarterly - Data Platform Admins group"
descriptionForAdmins = "Re-attest membership of the data platform admin group"
descriptionForReviewers = "Confirm each member still needs admin on the data platform."
scope = @{
"@odata.type" = "#microsoft.graph.accessReviewQueryScope"
query = "/groups/02f3bafb-448c-487c-88c2-5fd65ce49a41/transitiveMembers"
queryType = "MicrosoftGraph"
}
reviewers = @(
@{ query = "/groups/02f3bafb-448c-487c-88c2-5fd65ce49a41/owners"; queryType = "MicrosoftGraph" }
)
settings = @{
mailNotificationsEnabled = $true
reminderNotificationsEnabled = $true
justificationRequiredOnApproval = $true
recommendationsEnabled = $true # surface sign-in-based "approve/deny" hints
instanceDurationInDays = 14
autoApplyDecisionsEnabled = $true # ENFORCE the outcome
defaultDecisionEnabled = $true
defaultDecision = "Deny" # no response = revoke (deny-by-default)
recurrence = @{
pattern = @{ type = "absoluteMonthly"; dayOfMonth = 1; interval = 3 } # quarterly
range = @{ type = "noEnd"; startDate = "2026-07-01" }
}
}
}
New-MgIdentityGovernanceAccessReviewDefinition -BodyParameter $params
defaultDecision = "Deny" is the load-bearing choice. A reviewer who ignores the review removes the access by inaction. That is the only model that scales: it makes keeping access the deliberate act, not losing it. For lower-stakes reviews you may prefer "None" (no change on silence), but for privileged groups, deny-by-default is correct.
For application access reviews, scope to the app’s assigned users; for privileged role reviews, prefer reviewing the PIM-eligible assignments (next section) over active role members.
6. Govern guests and external access
Guests are the highest-decay population in any tenant - project-based, rarely offboarded by the inviting employee, and invisible to HR-driven lifecycle workflows because they have no employeeLeaveDateTime. Two controls contain them.
First, an inactive-guest review. Scope it to guests who have not signed in for a defined window using the inactive-users scope, and let the team owners decide:
$guestReview = @{
displayName = "Inactive guests on Teams (30d)"
descriptionForAdmins = "Remove guest access to teams with no recent sign-in."
instanceEnumerationScope = @{
"@odata.type" = "#microsoft.graph.accessReviewQueryScope"
query = "/groups?`$filter=(groupTypes/any(c:c+eq+'Unified'))"
queryType = "MicrosoftGraph"
}
scope = @{
"@odata.type" = "#microsoft.graph.accessReviewInactiveUsersQueryScope"
query = "./members/microsoft.graph.user/?`$filter=(userType eq 'Guest')"
queryType = "MicrosoftGraph"
inactiveDuration = "P30D"
}
reviewers = @( @{ query = "./owners"; queryType = "MicrosoftGraph" } )
settings = @{
mailNotificationsEnabled = $true
instanceDurationInDays = 7
autoApplyDecisionsEnabled = $true
defaultDecisionEnabled = $true
defaultDecision = "Deny"
recurrence = @{ pattern = @{ type = "absoluteMonthly"; dayOfMonth = 1; interval = 1 }
range = @{ type = "noEnd"; startDate = "2026-07-01" } }
}
}
New-MgIdentityGovernanceAccessReviewDefinition -BodyParameter $guestReview
Second, cross-tenant access settings. Decide centrally which partner tenants your users can be invited into (outbound) and which external tenants can be invited in (inbound), rather than letting any guest from any tenant land in your directory. Combine that with entitlement management’s external-user settings so guests provisioned through an externally visible catalog are auto-removed when their last access package assignment expires - closing the orphaned-guest gap by construction.
This makes the catalog, not a manual cleanup, the lifecycle boundary for external identities.
7. Integrate with PIM for time-bound privilege
Reviews and packages govern which roles a person can hold. PIM governs when they hold them. For any high-value role, the assignment delivered by governance should be eligible, not active - the person activates just-in-time, with justification and a time box.
The clean pattern: an access package or lifecycle task makes someone a member of a PIM-enabled group, and that group is itself eligible (not active) for the privileged role. Governance controls group membership; PIM controls activation. Create the eligible group-to-role relationship via the privileged access group schedule request:
New-MgIdentityGovernancePrivilegedAccessGroupEligibilityScheduleRequest -BodyParameter @{
accessId = "member" # eligible as a member of the group
principalId = "aaaaaaaa-0000-0000-0000-000000000001" # the user
groupId = "bbbbbbbb-0000-0000-0000-000000000002" # PIM-enabled privileged group
action = "adminAssign"
scheduleInfo = @{
startDateTime = (Get-Date).ToString("o")
expiration = @{ type = "afterDuration"; duration = "P90D" } # eligibility itself expires
}
justification = "Quarterly eligible assignment via governance"
}
Two expirations now stack: eligibility expires in 90 days (governed, re-attested by a review), and each activation expires in hours (PIM policy). A compromised account that has not activated holds no standing privilege, and even an active session is time-boxed. Then run an access review over the eligible assignments themselves so eligibility does not become the new standing privilege - PIM exposes reviews for eligible role and group assignments precisely for this.
Verify
Prove the machinery works before you trust it. Do not assume a workflow ran because it is enabled.
# 1. Access packages and their policies exist and are time-bound
Get-MgEntitlementManagementAccessPackage -All |
Select-Object DisplayName, Id
# 2. Lifecycle workflow run history - confirm tasks actually executed
$wf = Get-MgIdentityGovernanceLifecycleWorkflow -Filter "displayName eq 'Leaver - real-time offboard'"
Get-MgIdentityGovernanceLifecycleWorkflowRun -LifecycleWorkflowId $wf.Id |
Select-Object Id, LastUpdatedDateTime, ProcessingStatus, FailedTasksCount, SuccessfulUsersCount
# 3. Per-user task results for a failed run (root-cause individual failures)
Get-MgIdentityGovernanceLifecycleWorkflowRunTaskProcessingResult `
-LifecycleWorkflowId $wf.Id -RunId $runId |
Select-Object @{n='Task';e={$_.Subject.DisplayName}}, ProcessingResult, FailureReason
# 4. Access review decisions and whether they were applied
$def = Get-MgIdentityGovernanceAccessReviewDefinition -Filter "displayName eq 'Quarterly - Data Platform Admins group'"
$inst = (Get-MgIdentityGovernanceAccessReviewDefinitionInstance -AccessReviewScheduleDefinitionId $def.Id)[0]
Get-MgIdentityGovernanceAccessReviewDefinitionInstanceDecision `
-AccessReviewScheduleDefinitionId $def.Id -AccessReviewInstanceId $inst.Id |
Group-Object Decision | Select-Object Name, Count
A KQL backstop in the AuditLogs table confirms de-provisioning actually happened in the directory, independent of what the workflow reports:
AuditLogs
| where TimeGenerated > ago(7d)
| where LoggedByService == "Lifecycle Workflows"
| where ActivityDisplayName in ("Disable user account", "Remove user from all groups",
"Remove all licenses for user", "Revoke all refresh tokens for user")
| extend Target = tostring(TargetResources[0].userPrincipalName)
| project TimeGenerated, ActivityDisplayName, Target, Result
| order by TimeGenerated desc
For least-privilege posture reporting, age the entitlement-management assignment list: each assignment carries creation and (for expiring policies) expiry timestamps, so Get-MgEntitlementManagementAssignment grouped by access package and expiry answers “how much standing access exists and when does it drain.” Reviews emit a per-decision record (reviewedBy, decision, justification, timestamp) - export it per cycle for a defensible answer to “prove access was reviewed.”
Enterprise scenario
A 9,000-employee insurer ran SOX and faced a finding that hit every regulated firm eventually: claims adjusters could request the “Claims Write” access package and the “Payment Release” package, and 14 people held both. That combination let one person file and pay a fraudulent claim end-to-end. The external auditor wanted not just the toxic pairs removed, but a preventive control proving the combination could never recur - a quarterly cleanup spreadsheet was explicitly rejected as a detective control.
The platform team’s first instinct was a Sentinel rule to alert when both assignments coexisted. That failed the auditor for the right reason: it detects the violation after the access exists, leaving an exploitable window. The fix moved the control to request time. They marked the two packages mutually incompatible in entitlement management, so the second request is blocked - no toxic state is reachable. They then layered a quarterly review (deny-by-default) over the Payment Release package as the recurring re-attestation on top of the preventive gate.
# Preventive SoD: Payment Release becomes unrequestable for anyone holding Claims Write
New-MgEntitlementManagementAccessPackageIncompatibleAccessPackageByRef `
-AccessPackageId $paymentReleaseId `
-BodyParameter @{
"@odata.id" = "https://graph.microsoft.com/v1.0/identityGovernance/entitlementManagement/accessPackages/$claimsWriteId"
}
The reciprocal reference was added on the Claims Write package so the block holds in both directions. The 14 existing dual-holders were resolved through a one-time targeted review rather than bulk removal, preserving the access each legitimately needed. The auditor signed off on the incompatibility configuration plus the review schedule as a combined preventive-and-detective control. The lesson the team carried forward: SoD belongs at the request gate, not in the SIEM. A guardrail that prevents the state beats an alarm that reports it.