A service principal gets popped. How long until its token dies?

Traditional token expiry leaves a 60-90 minute window after you disable a compromised service principal. Continuous Access Evaluation closes that gap for service principals—here's how to enable it and test it with the Microsoft…

You find out at 2am that a service principal has been leaking data. You disable it, rotate the secret, and go back to bed feeling responsible. The attacker keeps reading your mailboxes for another hour, because the access token they already hold doesn’t care that the account behind it is disabled. Tokens are bearer instruments. Whoever holds one is trusted until it expires.

That gap — between “I revoked it” and “it stopped working” — is the entire problem Continuous Access Evaluation exists to close. It has done this for users for a while. The news worth your attention is that it now does it for service principals too, and the example everyone reaches for is an app already living in your tenant: the Microsoft Graph Command Line Tools.

75 minutes

That’s roughly the default lifetime of an access token, give or take the random jitter Entra adds so every token in your tenant doesn’t expire on the same tick. Without CAE, revocation is a lie you tell yourself. You flip accountEnabled to false, and the token issued five minutes ago sails on until minute 75. Refresh tokens are a separate, longer conversation, but the access token alone is enough to exfiltrate a lot in an hour.

CAE changes the model from expiry to evaluation. Instead of a resource trusting a token until its stamped end time, CAE-capable resource providers — Exchange Online, SharePoint, Teams, and Microsoft Graph itself — subscribe to a stream of critical events from Entra. When a revocation event fires, the resource re-checks and rejects the token on the next call. The token that was “valid for 70 more minutes” is dead on the next request instead.

The events that matter for a service principal are blunt and useful: the SP is disabled, the SP is deleted, or Entra flags it as high risk. Any of those propagates as a revocation signal. That’s the whole trick — no new token needed, no waiting for expiry, just the resource declining to honour what it’s handed.

14d82eec-204b-4c2f-b7e8-296a70dab67e

Memorise that GUID. It’s the well-known application ID for the Microsoft Graph Command Line Tools — the app that gets consented in your tenant the first time anyone runs Connect-MgGraph interactively. It’s the reason CAE for service principals stopped being academic.

Think about what this app actually is. It’s a public client, deployed in essentially every tenant that has ever touched the Graph SDK, and it accumulates delegated permissions like a coat rack accumulates coats. Every admin who consents to a new scope during a one-off script quietly widens what that single app can do on their behalf. Nobody owns it. Nobody reviews it. It is over-permissioned by accretion, not by decision.

So it’s a high-value target that’s invisible in exactly the way attackers like. If a session token for that app leaks — copied off a jump box, pulled from a token cache, phished through a device-code flow — the attacker inherits whatever the consenting admin could do. Being able to kill that specific app’s access without nuking every other integration in the tenant is the point of the exercise.

Two SKUs, and the second one surprises people

Here’s where I get impatient with the “just turn it on” framing. CAE for users rides along with Microsoft Entra ID P1 — the licence most enterprises already have. Fine. But Conditional Access targeting workload identities — which is how you attach policy to a service principal in the first place — needs the Microsoft Entra Workload ID add-on. That’s a separate, per-workload-identity SKU that is not in your E5, not in your P2, not hiding in a bundle you already bought.

The distinction that trips people up: CAE the enforcement mechanism is on by default for capable resources and doesn’t need the add-on. What needs the add-on is the Conditional Access policy for workload identities that gives CAE the risk and location conditions to act on. Manual revocation — you disabling the SP — works regardless. Automated, risk-driven revocation is the part behind the paywall. Decide which one you’re actually buying before you file the licensing request, because “CAE is included” and “the thing I want CAE to do is included” are not the same sentence.

Zero toggles labelled “enable CAE for this app”

Stop looking for that switch. It isn’t there. What you actually configure in the Entra admin center → Protection → Conditional Access is a policy that targets Workload identities as the assignment (instead of Users), scopes it to the service principals you care about, and applies conditions like sign-in risk or named locations. That policy plus default CAE gives you automated revocation on risk.

The manual lever lives elsewhere and is the one I’d reach for first during an incident: disabling the service principal. Go to Entra admin center → Identity → Applications → Enterprise applications, open the app, and set Enabled for users to sign in to No. With CAE-capable resources, that’s the event that gets tokens rejected in near-real-time rather than at expiry. Same action you always had. It just bites faster now.

One reviewed object ID, never a live query

Do not script this against a filtered query and pipe it straight into a disable. You will, on some bad morning, disable the wrong app and take down a production integration. Find the object, look at it, confirm it, then act on the specific ID. Here’s the pattern I use — read-only reconnaissance first, then a state change that defaults to -WhatIf.

audit.ps1PowerShell
Connect-MgGraph -Scopes 'Application.ReadWrite.All','AuditLog.Read.All' -NoWelcome
​
# --- 1. Reconnaissance: find the Graph CLI service principal (read-only) ---
$graphCliAppId = '14d82eec-204b-4c2f-b7e8-296a70dab67e'
​
try {
    $sp = Get-MgServicePrincipal -Filter "appId eq '$graphCliAppId'" -ErrorAction Stop
}
catch {
    Write-Error "Could not resolve the service principal: $($_.Exception.Message)"
    return
}
​
if (-not $sp) {
    Write-Warning "No service principal found for appId $graphCliAppId in this tenant."
    return
}
​
[pscustomobject]@{
    DisplayName    = $sp.DisplayName
    Id             = $sp.Id
    AppId          = $sp.AppId
    AccountEnabled = $sp.AccountEnabled
} | Format-List
​
# --- 2. Corroborate with recent sign-ins before you touch anything (paginated) ---
$signIns = @()
try {
    $uri = "https://graph.microsoft.com/beta/auditLogs/signIns" +
           "?`$filter=appId eq '$graphCliAppId'&`$top=50"
    do {
        $page     = Invoke-MgGraphRequest -Method GET -Uri $uri -ErrorAction Stop
        $signIns += $page.value
        $uri      = $page.'@odata.nextLink'
    } while ($uri)
}
catch {
    Write-Warning "Sign-in log query failed: $($_.Exception.Message)"
}
​
$signIns |
    Select-Object createdDateTime, ipAddress,
        @{n='Location';e={ $_.location.city }},
        @{n='Status'; e={ $_.status.errorCode }} |
    Sort-Object createdDateTime -Descending |
    Select-Object -First 15 |
    Format-Table -AutoSize

Only once you have eyes on the object and its recent activity do you pull the trigger. Note the object ID is pasted in deliberately — no pipeline from the query above — and the disable defaults to a dry run.

audit.ps1PowerShell
function Disable-ReviewedServicePrincipal {
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    param(
        [Parameter(Mandatory)][string] $ServicePrincipalId
    )
​
    try {
        $target = Get-MgServicePrincipal -ServicePrincipalId $ServicePrincipalId -ErrorAction Stop
    }
    catch {
        Write-Error "Object $ServicePrincipalId not found. Aborting."
        return
    }
​
    if ($PSCmdlet.ShouldProcess("$($target.DisplayName) ($ServicePrincipalId)", 'Disable service principal')) {
        try {
            Update-MgServicePrincipal -ServicePrincipalId $ServicePrincipalId -AccountEnabled:$false -ErrorAction Stop
            Write-Host "Disabled $($target.DisplayName). CAE-capable resources will reject its tokens shortly." -ForegroundColor Yellow
        }
        catch {
            Write-Error "Disable failed: $($_.Exception.Message)"
        }
    }
}
​
# Paste the object ID you confirmed above. Runs as -WhatIf by default:
Disable-ReviewedServicePrincipal -ServicePrincipalId '<confirmed-object-id>' -WhatIf
​
# Only when you mean it:
# Disable-ReviewedServicePrincipal -ServicePrincipalId '<confirmed-object-id>'

The tokens issued before you flipped the switch

Two limitations will bite you in six months if nobody writes them down now.

First: CAE is only as good as the resource on the other end. Exchange Online, SharePoint, Teams, and Graph honour the revocation signal. A third-party API sitting behind your own app registration almost certainly does not — it wasn’t built to subscribe to Entra’s event stream, so it happily accepts a revoked token until it expires the old-fashioned way. CAE for service principals is a preview capability, and the list of what actually enforces it is shorter than the marketing implies. Verify per resource; don’t assume.

Second: revocation is near-real-time, not instant, and it’s not retroactive in the way you’d hope. There’s a propagation window measured in minutes, not milliseconds — fine for an incident, not a substitute for scoping the app’s permissions down in the first place. And this is a scalpel, not a policy engine. It complements Conditional Access; it doesn’t replace it. If your only control is “we can revoke fast when something goes wrong,” you’ve automated the cleanup and skipped the prevention.

The operational cost is real and worth saying plainly: disabling a service principal breaks everything running under it. If that SP is the Graph CLI app, you may have just logged out every admin script in the tenant. During an active compromise that’s an easy call. On a hunch at 2am, it’s an outage you’ll be explaining at 9. Know which one you’re making before you run the second block.