You added them to the group and the app says no. Not a bug.

Entra ID is eventually consistent: writes succeed in one replica, but other replicas may return stale data for seconds to 30 minutes. The gap between 'I revoked access' and 'the change is enforced everywhere'…

Why can’t the user I just added to a group get into the app?

Because the write landed on one replica and the read hit another. You added them in the admin center, the change committed, the portal showed green. Ten seconds later they authenticate, the app asks for their group claim, and the replica answering that request hasn’t heard the news yet. From the app’s point of view the user was never in the group. From your point of view the change “worked.” Both are true at the same time. Welcome to a distributed directory.

Is this a bug I should raise a ticket for?

No. Don’t waste the support engineer’s afternoon. Entra ID is an eventually consistent system, and Microsoft said so out loud today in a way they probably should have plastered on the login page years ago. A successful write does not mean a consistent read everywhere. It means the write is durable and will propagate. Those are not the same promise, and mistaking one for the other is where half your “intermittent” access tickets come from.

Why does Entra do this instead of just being correct everywhere at once?

Because “correct everywhere at once” is a fantasy at this scale. Entra manages identities across data centres on every continent, and it has to keep answering token requests when a whole region falls over. Strong consistency — every read reflecting every write, instantly, globally — requires cross-region coordination on the write path. Locks. Quorums. Round trips across oceans before your change is allowed to commit.

Do that, and two things happen. Every write gets slower, and a network partition between regions means writes stop entirely until the partition heals. For an authentication system that is the whole business, that trade is insane. So Entra takes the other deal: accept the write locally, acknowledge fast, replicate in the background. You get availability and speed. You pay with a window where different replicas disagree. It’s the right call. It’s just a call nobody told you was being made on your behalf.

How long is “eventually,” really?

Usually seconds. Microsoft’s own framing is “typically within seconds, up to around 30 minutes in extreme cases.” There is no published SLA on replication lag, so don’t build anything that assumes a hard number — treat sub-minute as the happy path and 30 minutes as the case that eventually shows up in your worst incident. If you find yourself writing Start-Sleep -Seconds 5 and calling it handled, you’ve built a race condition with extra steps.

I just revoked an attacker’s admin role. Are they locked out?

No, and this is the one that actually hurts. Removing a role assignment is a write like any other — it has to replicate. Hit a stale replica in the next few minutes and that role is still effective. Worse, the attacker is probably holding an access token that was already valid when you clicked revoke, and access tokens don’t check back with Entra. They’re good until they expire, default an hour, regardless of what you did to the directory.

So “I removed their access, the threat is contained” is two claims and the second one is wrong. You removed a role assignment. Containment is a separate job.

Then what actually stops a live session?

Revoking the tokens, not just the role. During real incident response the order is: kill the sessions, then remove the standing access, then verify.

audit.ps1PowerShell
# Revoke a compromised user's refresh tokens / sign-in sessions.
# Review the target first — never pipe this straight off a live query.
Import-Module Microsoft.Graph.Users.Actions
​
$upn = '[email protected]'
​
try {
    $user = Get-MgUser -UserId $upn -ErrorAction Stop
    # Dry run by default. Drop -WhatIf only after you've confirmed the object.
    Revoke-MgUserSignInSession -UserId $user.Id -WhatIf
    Write-Host "Session revocation issued for $($user.UserPrincipalName)"
}
catch {
    Write-Error "Failed to revoke sessions for $upn : $($_.Exception.Message)"
}

Even this isn’t instant everywhere — the revocation event has to propagate and continuous access evaluation has to catch up on the resources that support it. It’s the right lever, but “issued” and “enforced on every endpoint” are, once again, different moments. Plan for the gap. Assume the attacker has one more hour of token life until proven otherwise.

My script writes and then reads back stale data. What did I do wrong?

Nothing, except trust the read. This is the classic footgun — write, immediately read, branch on the result:

audit.ps1PowerShell
# DON'T trust an immediate read-after-write. This can return stale membership.
Import-Module Microsoft.Graph.Groups
​
$groupId = '00000000-0000-0000-0000-000000000000'
$userId  = '11111111-1111-1111-1111-111111111111'
​
# State-changing: -WhatIf by default, run against reviewed IDs only.
New-MgGroupMember -GroupId $groupId -DirectoryObjectId $userId -WhatIf
​
# Poll for the change instead of assuming it — with pagination and a timeout.
$deadline = (Get-Date).AddMinutes(5)
$confirmed = $false
while ((Get-Date) -lt $deadline -and -not $confirmed) {
    try {
        $members = Get-MgGroupMember -GroupId $groupId -All -ErrorAction Stop
        if ($members.Id -contains $userId) {
            $confirmed = $true
            Write-Host "Membership confirmed as readable."
            break
        }
    }
    catch {
        Write-Warning "Read failed, will retry: $($_.Exception.Message)"
    }
    Start-Sleep -Seconds 10
}
​
if (-not $confirmed) {
    Write-Warning "Not yet consistent after 5 minutes — do not assume the app sees it."
}

Get-MgGroupMember -All pages for you; keep it, because “the user isn’t in the group” and “the user is on page two” look identical if you only read the first page. The pattern that survives contact with reality is poll until confirmed, with a timeout and a fallback — never write-then-branch.

Does the ConsistencyLevel header fix this?

It’s a different lever, not a magic one. Advanced Graph queries — $count, $search, certain $filter and $orderby combinations on directory objects — require the ConsistencyLevel: eventual header plus $count=true. That header opts you into a query mode against an index; it does not promise your last write is reflected. It’s about which query features you can use, not about making replication instant. Don’t confuse “eventual consistency query mode” with “wait for consistency.”

Where does this bite me in the admin center specifically?

Everywhere you make a change and then immediately verify from a different surface. Assign a Conditional Access policy and test sign-in from a browser thirty seconds later — the evaluating node may not have the new policy yet, so both “it blocked me” and “it let me through” are plausible for a few minutes. Delete a service principal and watch it linger in Graph list queries. Add a licence and watch the app refuse the feature until provisioning and replication both finish. Update group-based licensing and wait while the whole chain settles.

The fix isn’t technical, it’s expectational. Stop treating a green toast as proof of global state. Build the wait into your runbooks, tell the stakeholder “give it up to 30 minutes before we call it broken,” and put a poll-and-confirm loop into anything automated. The directory isn’t lying to you. It’s just answering from wherever you happened to land.

Eventual consistency is the price of a directory that never sleeps and never fully falls down. Fair trade. Just stop signing the contract without reading it.