Skip to content
Ops Log
Decision MemoSecurity & Infrastructure15 September 202611 min read

A daily offboarding check can prove closure only as a bound

A leaver check that runs once a day can report closure only as a bound between two runs. A Graph 429, including one inside a batch that returns 200, and a membership read that returns nulls can each make a degraded run look clean, so an absence counts only from a run that read cleanly.

Cover image for A daily offboarding check can prove closure only as a bound
MJ

A leaver check that runs once a day can report closure only as a bound: the account was present at run N and absent at run N plus 1, which ran H hours later. Nothing ran in between, so nothing can name the minute the account closed. The bound also carries a condition that is easy to drop. An absence counts only when the run that failed to see the account actually read everything it asked for, and two behaviours Microsoft documents for Graph can make an incomplete read look complete. A third, the restricted management administrative unit, makes the offboarding write itself fail while the automation that sent it may still log the job as done.

Last verified: 2026-09-15.

What Microsoft documents

Throttling on Microsoft Graph has a specific, checkable shape. The Microsoft Graph throttling guidance, read 2026-09-15, states that when a threshold is exceeded Graph "Limits any further requests from that client app for some time", "Returns HTTP status code 429 Too Many Requests and the requests fail", and "Returns a suggested wait time in the response header of the failed request". The documented recovery is to "Wait the number of seconds specified in the Retry-After header", retry, and read a second 429 as meaning "you're still being throttled". The same page describes the case that hides a 429 inside a success. In a JSON batch, "Requests in a batch are evaluated individually against throttling limits", a throttled request inside it fails with 429, and "The batch itself succeeds with a status code of 200 (OK)". The page adds that "throttled requests that were part of a batch aren't retried automatically", even where an SDK retries unbatched ones. A collector that checks only the outer status of a batch, or that stops paging after one failure and keeps what it already has, holds a result set smaller than the real one with nothing in it marking the gap.

A second mechanism changes what a read contains without changing its status code. The Overview of Microsoft Graph permissions, read 2026-09-15, states that when an application queries the membership of a container object "it receives a 200 OK response and a collection of objects", but "if the app doesn't have the permissions to read a certain object type in the container, the app receives objects of that type but with limited information", where "only the object type and ID might be returned and other properties are indicated as null". The page names the relationships this applies to, including /groups/{id}/members and /users/{id}/memberOf. An application missing one read permission therefore gets every member back at 200, some of them with null in every property except the id and the type. A check that looks for a leaver in a group's member list by user principal name rather than by id finds no match in that response and records the membership as removed. The Get a user page shows the same shape on a single object: for custom security attributes, the response when "the calling principal doesn't have access" is a 200 with the property set to null, identical to the response for a user who has none assigned.

A third mechanism sits on the write side. Restricted management administrative units in Microsoft Entra ID, read 2026-09-15, states that "Applications can't modify objects in restricted management administrative units by default", that granting that access means "you must assign a Microsoft Entra role to the application at the scope of the restricted management administrative unit", and that "If you assign Microsoft Graph application permissions to the application, those permissions won't apply because it's restricted." The page's operation table, written for administrators without a scoped assignment, blocks modifying "any Microsoft Entra properties of the user, group, or device", deleting the object, updating a user's password, and changing the owners or members of a group inside the unit. Reading standard properties, assigning licences and applying Intune policy stay allowed. Disabling an account changes one of its Microsoft Entra properties, so an offboarding automation holding a broad application permission such as User.ReadWrite.All can read a protected account and still fail to disable or delete it. The same page records a second boundary that matters to offboarding: users in these units "can't be managed with Microsoft Entra ID Governance features such as" Privileged Identity Management, Entitlement management, Lifecycle workflows and Access reviews.

Two more properties set the outer edges of what a daily check can observe. Restore or remove a recently deleted user, read 2026-09-15, states that "After you delete a user, the account remains in a suspended state for 30 days" and "After that 30-day window passes, the permanent deletion process automatically starts and can't be stopped." It also allows an administrator to "permanently delete a user from your organization without waiting the 30 days for automatic deletion". Inside the window a soft-deleted account can be read through List deletedItems (directory objects), GET /directory/deletedItems/microsoft.graph.user, where "The OData cast type is a required part of the URI", or one object at a time through Get deleted item (directory object); both pages list User.Read.All as the least privileged application permission for users. Separately, the signInActivity resource type, read 2026-09-15, describes the property as the "last interactive or non-interactive sign-in attempt time" and says Microsoft Entra ID stores it "for as long as the user object exists", and Log latency in Microsoft Entra ID, read the same day, says the property "might take up to 24 hours to update".

Why a degraded read looks clean

The plausible misreading is that a scheduled check either worked or failed, and that a failure would show in the job log. The two read mechanisms above do not behave that way by default. A 429 on one page of a paginated scan can leave accounts unread while the run still exits cleanly, if the calling code treats a caught exception as the end of the data. A 429 inside a batch arrives under an outer 200. A limited-information membership read arrives as a 200 with a complete object count, so a sanity check of the form "did the collection come back the expected size" passes while the entries it needed carry nulls. The restricted administrative unit case fails differently: the next read still finds the account enabled, so the bound correctly stays open, but an automation that records only whether the offboarding job ran will report a disable that never happened.

The cost lands on whoever reads the output next. A leaver check that records an account as closed because a throttled page returned fewer rows has certified a remediation nobody performed, on a schedule nobody watches between runs. An operator who has to forward evidence of access termination, the document a daily offboarding check exists to produce, hands over a bound that silently assumes every read behind it was complete.

Recommendation

Report offboarding closure as a pair of runs with the hours between them, H: the last run that saw the account present and the first healthy run that saw it absent. Record a health flag per run and let an absence count only from a run whose flag is true. Set the flag to false on either documented read signal: a 429 anywhere in the scan, batched or not, or a read that returned null for a property the check depends on, including accountEnabled itself. A degraded run that still saw the account is valid evidence that it was present. A degraded run that did not see it proves nothing, so the bound stays open and its upper end moves to the next healthy run. Record a restricted-administrative-unit refusal as its own state: the account is genuinely still there, so the entry reads open, write refused, with the refusal attached. A bound built this way can be checked by anyone against the account's own accountEnabled value or the deleted items collection.

Source, read 2026-09-15What it documentsEffect on a daily leaver check
Microsoft Graph throttling guidance429 with a Retry-After header; a throttled request inside a JSON batch under an outer 200A collector that ignores the header or the inner statuses holds fewer rows with no marker
Overview of Microsoft Graph permissionsA 200 OK membership read with null properties for object types the app cannot readA match on user principal name fails and a membership reads as removed
Restricted management administrative unitsApplication permissions do not apply to modify calls without a role scoped to the unitThe disable or delete never happens and the account correctly stays present
Restore or remove a recently deleted user30 days suspended, then permanent deletion; an administrator can delete permanently soonerAn account missing from both users and deleted items cannot be confirmed as deleted by either read

Trade-offs

Running the check more often shrinks H, the width of the bound, and it costs exactly where the first risk sits. The throttling guidance lists "Reduce the frequency of calls." among its best practices and names "regularly scanning resource collections to check for new or deleted resources" as a pattern "more likely to lead to applications being throttled", pointing to change tracking and change notifications instead where they are available. A tighter schedule therefore raises the odds of the 429 the health flag exists to catch. It does nothing for the other two mechanisms. A limited-information membership read returns the same nulls on every attempt until the missing permission is granted, and an unscoped write into a restricted administrative unit is refused every hour as reliably as once a day. Those two are fixed by a permission and by a scoped role; the schedule only changes the width of H.

Where it does not apply

The bound never closes for an account inside a restricted management administrative unit while the automation holds no role scoped to that unit, because the write the bound waits for is refused every time. That calls for a scoped role assignment, and a check that keeps reporting the account as open is correct. The same page says Lifecycle workflows cannot manage users in these units, so an offboarding flow built on that feature does not reach them either. signInActivity cannot stand in as a closure signal: it records sign-in attempts, can lag by up to 24 hours on Microsoft's own account, and is kept for as long as the user object exists, so a disabled account keeps its last values. The deleted-items confirmation stops working once the 30-day window has passed, and earlier for any account an administrator deleted permanently. An account missing from both the users collection and deleted items is therefore unconfirmed, and a check recovering from an outage longer than 30 days needs a second confirmation path before it records anything.

http
# Read-only: two GET requests, no request body, nothing written.
# Application permission: User.Read.All, the least privileged listed for users
# on the Get a user and Get deleted item pages. Get a user also names
# User.EnableDisableAccount.All plus User.Read.All for accountEnabled,
# so treat a null accountEnabled as a degraded read, never as disabled.

GET https://graph.microsoft.com/v1.0/users/{id}?$select=id,accountEnabled
Authorization: Bearer {token}

# Get a user returns 404 Not Found when no user has that ID.
# Before recording the account as absent, look for it in deleted items:
GET https://graph.microsoft.com/v1.0/directory/deletedItems/{id}
Authorization: Bearer {token}

# Record the HTTP status of every response, and Retry-After on a 429.
#   200 on the first call: present; accountEnabled says whether it is disabled
#   200 on the second call: soft deleted, inside the 30-day window
#   429 on either call: this run is degraded, record no closure for it
#   missing from both: unconfirmed, record no closure from this alone

Limitations

None of this proves what a remediation call changed. The vendor pages establish what a throttled response, a limited-information read and a restricted-administrative-unit refusal each look like, and this note derives from them what a check should record. They do not say how often any of the three occurs in a given tenant, which depends on call volume, administrative unit design and the permissions granted to the calling identity; Microsoft publishes no such rate and this note has not measured one. The HTTP status an application receives when a restricted administrative unit refuses its write was not found on any page read for this note, so no status code is given for that case. A health flag is only as trustworthy as the code that sets it, which this note does not audit.

The recurring check behind Offboarding Evidence is written to this rule: it states a closure only as absence at the next scheduled check with the hours between, and when the later scan did not read cleanly it records no closure for that departure.

Sources and further reading

Was this field note useful?
Make the decision

Turn the trade-off into a scoped brief.

Share the constraints that differ in your environment. Michal will identify the next check needed before a delivery decision.

Start a scoped brief