Three things that broke when one token could impersonate any admin

Microsoft patched a maximum-severity flaw that let an attacker forge a token in their own tenant and use it to impersonate Global Admin in yours—without tripping your sign-in logs. Here's how to audit for…

Start with the claim that ruins your afternoon. Per Microsoft’s advisory, an attacker holding a token minted in their own throwaway tenant could have impersonated your Global Administrator, read your directory, and left — without ever tripping your sign-in logs. Microsoft tracks it as CVE-2025-55241 and rates it at the top of the CVSS scale. The company also says it found no evidence anyone exploited it. Cold comfort, given how little evidence there would have been to find.

I’m working from the public disclosure here, not a private briefing — so where I say “reportedly,” take it literally. But the mechanism, as described, is the whole story. Let me build it from the ground up.

Tokens, and the master key nobody documented

A normal Entra ID token is a wristband. You authenticate, the club hands you a wristband, and every door checks it against what it’s allowed to open. Fine. Auditable. Every scan leaves a trace.

Then there are actor tokens — internal, largely undocumented credentials Microsoft’s own services reportedly used to talk to each other on your behalf. Think staff master key, not guest wristband. They were issued by a legacy backend, and critically, using one didn’t produce the normal sign-in record. Staff don’t badge in through the front-door scanner.

The bug lived in the old Azure AD Graph API (graph.windows.net), the deprecated predecessor to Microsoft Graph. When it received one of these master keys, it reportedly failed to check which building issued it. A key minted in the attacker’s tenant was accepted as valid in yours. Cross-tenant, by design failure.

The three things that broke

  1. Tenant isolation. The one guarantee multi-tenant cloud sells you — my tenant is not your tenant — did not hold at the legacy API layer.
  2. Identity itself. The token could impersonate any user in the target tenant, up to and including Global Admin. Authentication wasn’t bypassed so much as counterfeited.
  3. Your ability to see it. Because actor tokens against the legacy API sidestepped normal sign-in logging, the victim tenant had little to no telemetry. You can’t alert on what was never written down.

Who was exposed, and what to do now

Effectively everyone, and the reason is structural. A flaw in the shared identity plane isn’t gated by licence the way a feature you switch on is — Free, P1, and P2 all sit on the same plumbing. There’s no per-tier toggle that would have saved you. There’s also no patch for you to deploy: Microsoft says it remediated this on its own infrastructure, which is how cloud-service flaws work. You don’t get a KB and a reboot; you get a sentence in an advisory. Microsoft has described this as a proactive disclosure rather than a live incident.

So the honest remediation list is short and slightly deflating. You can’t re-secure something Microsoft already closed. What you can do is check whether your privileged surface changed while the door was open, and finally kill off the legacy Azure AD Graph app you’ve been meaning to retire for two years.

A read-only audit

This makes no changes. It enumerates who holds Global Admin, pulls recent role-assignment events, and looks for any traffic against the legacy API. Review the output — don’t act on it blind.

audit.ps1PowerShell
#requires -Modules Microsoft.Graph.Authentication
# READ-ONLY. No state changes. Review output before acting on it.
​
Connect-MgGraph -Scopes @(
    'RoleManagement.Read.Directory',
    'Directory.Read.All',
    'AuditLog.Read.All'
) -NoWelcome
​
try {
    $gaRole = Get-MgDirectoryRole -All -ErrorAction Stop |
              Where-Object { $_.DisplayName -eq 'Global Administrator' }
    if ($gaRole) {
        $admins = Get-MgDirectoryRoleMember -DirectoryRoleId $gaRole.Id -All -ErrorAction Stop
        Write-Host "Global Administrators: $($admins.Count)" -ForegroundColor Cyan
        $admins | ForEach-Object {
            [pscustomobject]@{
                Id   = $_.Id
                Type = $_.AdditionalProperties['@odata.type']
                UPN  = $_.AdditionalProperties['userPrincipalName']
            }
        } | Format-Table -AutoSize
    }
}
catch { Write-Warning "Role enumeration failed: $($_.Exception.Message)" }
​
try {
    $since  = (Get-Date).AddDays(-30).ToString('yyyy-MM-ddTHH:mm:ssZ')
    $filter = "activityDateTime ge $since and " +
              "(activityDisplayName eq 'Add member to role' or " +
              "activityDisplayName eq 'Add eligible member to role')"
    $events = Get-MgAuditLogDirectoryAudit -Filter $filter -All -ErrorAction Stop
    Write-Host "Role-assignment events (30d): $($events.Count)" -ForegroundColor Cyan
    $events | Select-Object ActivityDateTime, ActivityDisplayName,
        @{n='Initiator';e={$_.InitiatedBy.User.UserPrincipalName}} |
        Sort-Object ActivityDateTime -Descending | Format-Table -AutoSize
}
catch { Write-Warning "Audit-log query failed: $($_.Exception.Message)" }
​
try {
    $aadGraphAppId = '00000002-0000-0000-c000-000000000000'  # legacy Azure AD Graph
    $since  = (Get-Date).AddDays(-7).ToString('yyyy-MM-ddTHH:mm:ssZ')
    $filter = "createdDateTime ge $since and appId eq '$aadGraphAppId'"
    $signIns = Get-MgAuditLogSignIn -Filter $filter -All -ErrorAction Stop
    Write-Host "Legacy Azure AD Graph sign-ins (7d): $($signIns.Count)" -ForegroundColor Cyan
    $signIns | Select-Object CreatedDateTime, UserPrincipalName, AppDisplayName, IPAddress |
        Sort-Object CreatedDateTime -Descending | Select-Object -First 50 | Format-Table -AutoSize
}
catch { Write-Warning "Sign-in query failed: $($_.Exception.Message)" }

In the admin center, mirror this by hand: Entra ID > Sign-in logs, filter Application to Azure Active Directory Graph; and Audit logs for Add member to role and Add eligible member to role. Cross-check any privileged addition against a change ticket.

One caveat you must sit with: because the exploit path reportedly avoided normal logging, a clean result here is not proof you were untouched. It’s the best you’ve got, which is a different thing. The lasting lesson isn’t about one CVE — it’s that a deprecated API you never fully switched off was quietly load-bearing for the entire trust model. Go turn it off.