Most tenants do not have an access-review problem; they have an access-review ritual: an analyst exports group memberships to a spreadsheet once a quarter, emails it to managers who rubber-stamp it, and files the PDF for the auditor. That satisfies nobody. It does not scale past a few dozen groups, it produces no machine-readable decision history, and it never actually removes anyone because “remove” means a follow-up ticket someone forgets to file. Entra ID Identity Governance gives you a real engine for this: reviews that enumerate the right principals, route to the right reviewer, capture a decision per principal, and auto-apply the result so a “deny” is a removal, not a note. This article designs that program end to end. It assumes Entra ID Governance licensing (or the bundled Entra Suite) for access reviews on roles and groups, and P2 for PIM-eligible role reviews; the API examples use Microsoft Graph v1.0 with the accessReviews resource set.
1. Scope the program before you create a single review
The fastest way to make access reviews unusable is to review everything every month. Reviewer fatigue is the failure mode; once owners learn that “Approve all” is the path of least resistance, every signal you collect is noise. Scope deliberately along two axes: what you review and how often.
| Review target | Resource type | Cadence | Reviewer |
|---|---|---|---|
| Privileged directory roles (Global Admin, Privileged Role Admin, etc.) | Entra role assignments + PIM-eligible | Monthly or quarterly | Self + a second-stage approver |
| Tier-0 / high-value security groups (admins, prod break-glass) | Group membership | Quarterly | Group owner, fallback to a named team |
| App assignments to enterprise apps (especially SAML/SCIM-provisioned) | Application assignments | Quarterly or semi-annual | App owner / business owner |
| B2B guest accounts across all groups and apps | Guests (membership.everyone scope) | Quarterly | Self, fallback to inviting sponsor |
| Standard internal group membership | Group membership | Semi-annual or annual | Manager or group owner |
Two design rules carry the whole program. First, the cadence should match the blast radius, not the headcount. A 4-person group that grants Global Admin deserves a monthly review; a 4,000-person “All Engineering” distribution group does not need one at all. Second, review eligibility, not just activation, for privileged roles. If you only review who activated Global Admin last quarter, you miss the dormant eligible assignment that is the actual standing risk. PIM-eligible assignments are the standing grant; that is what recertification is for.
A note on what access reviews cannot target so you scope realistically: they review group membership, app role assignments, Entra role assignments (active and PIM-eligible), and PIM for Groups. They do not review Azure resource RBAC (subscription/management-group role assignments) directly — PIM for Azure resources has its own review surface, and Azure RBAC at the data plane needs a different control. Do not promise an auditor that an Entra access review covers subscription Owner assignments; it does not.
2. Recertify privileged role assignments on a recurring cadence
Privileged role reviews are the highest-value reviews you will run, so build them first and build them in the PIM experience, not the generic group-review surface — that is where eligible assignments live. In the portal the path is Entra ID > Identity Governance > Privileged Identity Management > Roles (or the dedicated Access reviews node under PIM), where you scope a review to specific directory roles and choose whether to evaluate eligible, active, or both assignment types.
The defaults that matter for a privileged role review:
- Scope: pick the specific roles, not “all roles.” Review Global Administrator, Privileged Role Administrator, Privileged Authentication Administrator, Security Administrator, Application Administrator, and User Administrator as a tight high-tier set on the tightest cadence.
- Assignment type: review eligible + active. Eligible is the standing grant; active catches anyone who was assigned permanently outside PIM.
- Reviewers: for roles, self-review (“Users review their own access”) plus a second stage approver is the pattern that satisfies separation-of-duties without overwhelming a single security owner. More on multi-stage in the next section.
- Require justification on approval. For privileged roles, an approval with no written reason is worthless evidence. Force the reviewer to state why the person still needs Global Admin.
Here is the same review authored against Graph. This creates a recurring monthly review of two privileged roles, scoped to eligible assignments, with auto-apply on:
Connect-MgGraph -Scopes "AccessReview.ReadWrite.All"
# Role definition IDs are well-known GUIDs; these two are the same in every tenant.
$globalAdmin = "62e90394-69f5-4237-9190-012177145e10" # Global Administrator
$privRoleAdmin = "e8611ab8-c189-46e8-94e1-60213ab1f814" # Privileged Role Administrator
$body = @{
displayName = "Quarterly recert - Tier-0 directory roles"
descriptionForAdmins = "Recertify eligible assignments to Global Admin and Privileged Role Admin."
scope = @{
"@odata.type" = "#microsoft.graph.principalResourceMembershipsScope"
principalScopes = @(
@{ "@odata.type" = "#microsoft.graph.accessReviewQueryScope"
query = "/users"; queryType = "MicrosoftGraph" }
)
resourceScopes = @(
@{ "@odata.type" = "#microsoft.graph.accessReviewQueryScope"
query = "/roleManagement/directory/roleDefinitions/$globalAdmin"
queryType = "MicrosoftGraph" }
@{ "@odata.type" = "#microsoft.graph.accessReviewQueryScope"
query = "/roleManagement/directory/roleDefinitions/$privRoleAdmin"
queryType = "MicrosoftGraph" }
)
}
}
We will finish the settings block (reviewers, recurrence, auto-apply) in section 4 and POST it once. The point here: privileged role reviews are scoped by role definition as the resource and users as the principal — that combination is what principalResourceMembershipsScope expresses, and it is the only scope shape that lets one review span multiple roles.
3. Choose reviewers deliberately: self, manager, owner, and multi-stage chains
The reviewer choice is the single biggest determinant of whether a review produces signal or noise. Entra gives you four primitives, and they are not interchangeable:
| Reviewer type | Best for | Failure mode |
|---|---|---|
| Self-review | Privileged roles, guest access — the person attests they still need it | Honest-but-lazy “yes I still need it”; pair with a second stage |
| Manager | Standard internal membership where HR hierarchy is clean | Breaks for guests (no manager) and for matrixed orgs |
| Group owner / app owner | Group membership, app assignments — owner knows the business reason | Ownerless groups produce no reviewer; needs a fallback |
| Specific reviewer(s) | A named security team as the backstop for everything | Becomes a bottleneck if used as the primary reviewer at scale |
The pattern that actually holds up at scale is the multi-stage review: stage 1 is self-review (cheap, high coverage), and stage 2 is the manager or a security owner who only sees the principals stage 1 approved — or, better, only sees what they need to. Multi-stage reviews let you say “second-stage reviewers see stage-1 decisions” so the security owner is auditing the attestations, not re-doing them. You can also configure stage 2 to run only when stage 1 approved, so denials short-circuit and apply immediately.
"stageSettings": [
{
"stageId": "1",
"durationInDays": 5,
"reviewers": [ { "query": "./manager", "queryType": "MicrosoftGraph", "queryRoot": "decisions" } ],
"fallbackReviewers": [
{ "query": "/groups/<security-team-group-id>/members", "queryType": "MicrosoftGraph" }
]
},
{
"stageId": "2",
"dependsOn": [ "1" ],
"durationInDays": 5,
"reviewers": [ { "query": "/groups/<security-team-group-id>/members", "queryType": "MicrosoftGraph" } ]
}
]
Note "./manager" with queryRoot: "decisions" — that is the Graph idiom for “the manager of each reviewed user.” For groups, swap stage 1 to the group owner with query: "./owners". The fallback reviewer is what saves you when the manager is empty or the group has no owner; without it, those principals land on no reviewer’s plate and silently expire (and what happens then depends on your no-response setting — section 4).
4. Auto-apply, “remove if no response”, and sign-in-based recommendations
A review that does not change anything is theater. The settings that make a review operational live in the settings (or per-review-instance) block:
autoApplyDecisionsEnabled: true— when the review closes, decisions are enforced automatically. Deny means the assignment is removed. Without this, an admin must manually click “Apply,” and in practice nobody does.defaultDecision— what to do for principals nobody reviewed. The three values areApprove,Deny, andRecommendation. For privileged and guest reviews, setDeny: no response is a removal. This is the “remove if no response” behavior, and it is the most important compliance lever you have.recommendationsEnabled: true— Entra computes a recommendation per principal based on sign-in activity, by default a 30-day inactivity window (configurable to 30/60/90 days viarecommendationLookBackDuration). A user who has not signed in to the resource recently is recommended for denial. Reviewers see this inline, and you can setdefaultDecision: Recommendationto apply the recommendation for non-responders.justificationRequiredOnApproval: true— forces a written reason on approve. Non-negotiable for privileged roles.recurrence— the cadence. Monthly, quarterly, etc., with an end date or open-ended.
Here is the full settings block I attach to the privileged role review from section 2, then the POST that creates it:
$body.settings = @{
mailNotificationsEnabled = $true
reminderNotificationsEnabled = $true
justificationRequiredOnApproval = $true
defaultDecisionEnabled = $true
defaultDecision = "Deny" # remove if no response
instanceDurationInDays = 14
autoApplyDecisionsEnabled = $true # enforce on close
recommendationsEnabled = $true # sign-in-based recommendations
recommendationLookBackDuration = "P30D" # 30-day inactivity window
recurrence = @{
pattern = @{ type = "absoluteMonthly"; interval = 3; dayOfMonth = 1 } # quarterly
range = @{ type = "noEnd"; startDate = "2026-07-01" }
}
reviewers = @(
@{ query = "/users"; queryType = "MicrosoftGraph"; queryRoot = $null } # self-review
)
}
New-MgIdentityGovernanceAccessReviewDefinition -BodyParameter $body
defaultDecision: DenywithautoApplyDecisionsEnabled: trueis a loaded gun. A review that no one responds to will remove every assignment in scope when it closes. That is exactly what you want for stale guests and dormant role grants — and exactly what will cause a Sev1 if you point it at a group that gates production access and the owner is on PTO. Pair it with the exclusions and break-glass safeguards in section 8 before you flip auto-apply on a Tier-0 scope.
5. Inactive-guest cleanup: reviews plus last-sign-in telemetry
Stale guests are the most common audit finding and the easiest win. The mistake is treating it as a one-time cleanup. Stand up a recurring guest review scoped to “everyone” — every guest who is a member of any group or assigned to any app — with self-review (the guest attests), a fallback to the inviting sponsor, recommendations on, and defaultDecision: Deny. A guest who ignores the email and has no recent sign-in gets removed automatically.
Combine that with signInActivity telemetry so you are not waiting a full cycle to find the truly dead accounts. This Graph query finds guests with no interactive sign-in in over 90 days — your candidate list to either pre-clean or feed into a targeted review:
# Guests with last interactive sign-in older than 90 days (or never signed in).
# signInActivity requires AuditLog.Read.All and Entra ID P1/P2.
az rest --method get \
--url "https://graph.microsoft.com/v1.0/users?\$filter=userType eq 'Guest' and signInActivity/lastSignInDateTime le 2026-03-08T00:00:00Z&\$select=displayName,mail,signInActivity,externalUserState&\$count=true" \
--headers "ConsistencyLevel=eventual"
externalUserState is the other half: a guest stuck in PendingAcceptance who never redeemed their invite is dead weight you can remove without a review at all. Two caveats that bite people: signInActivity is not populated instantly (allow for latency, and it excludes non-interactive sign-ins by default), and a guest can appear “inactive” in your tenant while being active in their home tenant — last sign-in here is the right signal precisely because it measures access to your resources.
6. Generate audit evidence and decision history for SOX and ISO
This is where access reviews earn their licensing. Every review produces a per-principal decision record — who decided, what they decided, when, the justification, and whether it was auto-applied — and that is exactly the artifact a SOX or ISO 27001 (A.9 / A.5.18 access-control) auditor wants. The job is to extract it as evidence, not screenshots.
Pull the decisions for a completed review instance via Graph:
# List instances of a review definition, then pull decisions for the latest one.
az rest --method get \
--url "https://graph.microsoft.com/v1.0/identityGovernance/accessReviews/definitions/<definition-id>/instances/<instance-id>/decisions?\$select=principal,decision,reviewedBy,reviewedDateTime,justification,appliedDateTime,applyResult"
The fields that matter for evidence: decision (Approve/Deny/NotReviewed/DontKnow), reviewedBy (the actual reviewer identity — critical for separation-of-duties proof), justification, appliedDateTime and applyResult (proof the decision was enforced, closing the loop an auditor cares about most). For a control narrative you typically need three things, and reviews give you all three: the review ran on schedule (recurrence + instance start/end dates), the right people reviewed (reviewedBy vs. expected reviewer), and denials were actioned (applyResult: "New" / removed). Export this to your evidence store (a SharePoint library, a GRC tool, an immutable blob) on a schedule; do not rely on the 30-day default retention of the review UI for long-horizon SOX evidence.
One more durable evidence source: access-review activity also lands in the Entra audit logs (category: "UserManagement" / access-review events), which you should already be exporting to a Log Analytics workspace or SIEM via a diagnostic setting. That gives you a tamper-evident, long-retention copy independent of the governance UI.
7. Automate creation and result extraction with the Graph access-reviews API
Click-ops does not survive a tenant with hundreds of groups. Treat reviews as code. The lifecycle is four endpoints under identityGovernance/accessReviews/definitions:
| Operation | Method + path |
|---|---|
| Create a recurring review | POST /identityGovernance/accessReviews/definitions |
| List review definitions | GET /identityGovernance/accessReviews/definitions |
| List instances of a review | GET .../definitions/{id}/instances |
| Pull decisions for an instance | GET .../definitions/{id}/instances/{id}/decisions |
A pragmatic automation runs daily, finds review instances that completed since the last run, and ships their decisions to your evidence store. The PowerShell Microsoft.Graph.Identity.Governance module wraps all of it:
Connect-MgGraph -Scopes "AccessReview.Read.All"
# Find instances that completed in the last 24h and export their decisions.
$since = (Get-Date).AddDays(-1).ToString("o")
$defs = Get-MgIdentityGovernanceAccessReviewDefinition -All
foreach ($def in $defs) {
$instances = Get-MgIdentityGovernanceAccessReviewDefinitionInstance `
-AccessReviewScheduleDefinitionId $def.Id -All |
Where-Object { $_.Status -eq "Completed" -and $_.EndDateTime -ge $since }
foreach ($inst in $instances) {
$decisions = Get-MgIdentityGovernanceAccessReviewDefinitionInstanceDecision `
-AccessReviewScheduleDefinitionId $def.Id `
-AccessReviewInstanceId $inst.Id -All
$decisions | Select-Object `
@{n='Review';e={$def.DisplayName}}, @{n='Principal';e={$_.Principal.AdditionalProperties.displayName}}, `
Decision, @{n='ReviewedBy';e={$_.ReviewedBy.DisplayName}}, ReviewedDateTime, Justification, ApplyResult |
Export-Csv "./evidence/$($def.Id)-$($inst.Id).csv" -NoTypeInformation
}
}
Run this from an Azure Automation runbook or a scheduled GitHub Actions job using a workload identity with federated credentials (no stored secret) granted AccessReview.Read.All. For creating reviews from code, define them as JSON templates in a repo and POST on change — that gives you peer review and history on the review definitions themselves, which is the same governance discipline you apply to Conditional Access as code.
8. Avoid lockout: exclusions, fallback reviewers, and break-glass safeguards
autoApplyDecisionsEnabled + defaultDecision: Deny is the right design and also the thing that can lock you out of your own tenant. Three safeguards are mandatory before you point that combination at any privileged scope:
- Exclude break-glass accounts from every privileged review’s scope. Your two cloud-only emergency-access Global Admins must never be reviewable, because a “no response” must never remove their access. Reviews scope by principal query, so exclude them by keeping them out of the reviewed population — the cleanest approach is to ensure break-glass accounts hold their Global Admin assignment outside PIM as a permanent active assignment and review only eligible assignments, so the break-glass active grant is structurally out of scope. Verify it explicitly; do not assume.
- Always set fallback reviewers on owner- and manager-based reviews (section 3). A group with no owner and no fallback produces zero reviewers, and with
defaultDecision: Denythat means everyone in the group gets removed on close. The fallback turns “nobody reviewed it” into “the security team reviewed it.” - Stage your rollout of auto-apply. Run the first one or two cycles with
autoApplyDecisionsEnabled: falseandrecommendationsEnabled: true. You get the recommendations and the decision history without enforcement, so you can validate that the scope, reviewers, and recommendations are sane before a denial actually removes access. Flip auto-apply on only after a clean dry run.
Treat your break-glass accounts as a hard invariant the whole program is built around: cloud-only, FIDO2 or a long stored credential in a vault, excluded from Conditional Access blocking policies and from access-review scope, and alerted on every sign-in. If an access review can remove access from a break-glass account, the program is misconfigured, full stop.
Enterprise scenario
A platform team running a multi-tenant SaaS for a regulated customer had ~3,100 B2B guests accumulated over three years of customer-collaboration projects — SharePoint shares, Teams, and a handful of SCIM-provisioned apps. A SOC 2 + ISO 27001 audit flagged “no evidence of periodic guest access recertification,” and the auditor wanted both removal of stale accounts and recurring proof going forward. The constraint: the team could not manually review 3,100 guests, and most guests had no manager or sponsor recorded in the directory, so manager-based review was a non-starter.
They built a single recurring quarterly guest review scoped to “everyone” (all guests who are members of any group or assigned to any app) with: self-review as stage 1, recommendationsEnabled with a 90-day look-back, defaultDecision: Deny, and a fallback reviewer pointing at a named IT-security group for guests who never responded. The first cycle ran with autoApplyDecisionsEnabled: false to validate, then enforcement was turned on for cycle two. The first enforced cycle removed ~1,400 guests (no sign-in in 90 days and no self-attestation), and the per-principal decision CSV — reviewedBy, justification, applyResult — became the audit artifact directly. The look-back duration was the load-bearing setting:
{
"settings": {
"recommendationsEnabled": true,
"recommendationLookBackDuration": "P90D",
"defaultDecisionEnabled": true,
"defaultDecision": "Deny",
"autoApplyDecisionsEnabled": true,
"recurrence": { "pattern": { "type": "absoluteMonthly", "interval": 3 },
"range": { "type": "noEnd" } }
}
}
The lesson that generalized to the rest of their program: recommendations plus defaultDecision: Deny does the work that humans will not. Self-review gives honest active users a one-click path to keep access; everyone who has genuinely gone stale is removed by inaction. They never had to find a manager for 3,100 guests.
Verify
Confirm the program is actually operating, not just configured:
# 1. The review definition exists and recurs as intended.
Get-MgIdentityGovernanceAccessReviewDefinition -All |
Select-Object DisplayName, @{n='Recurrence';e={$_.Settings.Recurrence.Pattern.Type}},
@{n='AutoApply';e={$_.Settings.AutoApplyDecisionsEnabled}},
@{n='DefaultDecision';e={$_.Settings.DefaultDecision}}
# 2. At least one instance has actually run and completed.
Get-MgIdentityGovernanceAccessReviewDefinitionInstance `
-AccessReviewScheduleDefinitionId <definition-id> -All |
Select-Object Status, StartDateTime, EndDateTime
- Recurrence is set (not a one-time review) on every program-level review.
- A completed instance exists with a sane start/end and a non-empty decision set.
applyResultis populated on denied decisions — proof enforcement actually fired, not just that a decision was recorded.- Break-glass accounts are absent from every privileged review’s decision list. Open the latest instance and confirm by inspection.
- Sign-in telemetry is flowing: a
signInActivityquery returns reallastSignInDateTimevalues for guests (if all null, P1/P2 or audit-log permissions are missing and recommendations will be wrong).