Identity Azure

Building an Access Reviews Program in Entra ID: Recertifying Privileged Roles, Groups, and Guest Access at Scale

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:

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:

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: Deny with autoApplyDecisionsEnabled: true is 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:

  1. 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.
  2. 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: Deny that means everyone in the group gets removed on close. The fallback turns “nobody reviewed it” into “the security team reviewed it.”
  3. Stage your rollout of auto-apply. Run the first one or two cycles with autoApplyDecisionsEnabled: false and recommendationsEnabled: 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

Checklist

Entra IDIdentity GovernanceAccess ReviewsGuest AccessCompliance

Comments

Keep Reading