MFA Won the Human War. Your Service Principals Never Got the Memo.

Human identities got Conditional Access, MFA, and risk scoring. Service principals got a 24-month client secret and zero monitoring. Count what's running in your tenant before someone else does.

The 2 a.m. page is never the user who forgot their password. It’s the pipeline that’s been authenticating fine for 14 months and suddenly isn’t, or the one that’s been authenticating fine for 14 months and suddenly shouldn’t be, because the secret leaked into a public repo in March and someone finally noticed. Human identities got the full security budget: Conditional Access, phishing-resistant MFA, risk scoring. The identities doing the actual work got a client secret and a shrug.

Let’s count what’s actually in the building.

0, the MFA prompts a service principal will ever see

None. There’s nobody to tap a phone. Service principals, managed identities, and app registrations authenticate with a credential, a certificate, a secret, or a federated token, and that’s the whole story. Every control you built for humans assumes a human is present to be challenged. Non-human identities (NHIs) sidestep the lot. That’s not a bug; it’s the design. It’s also why one leaked secret is a straight line to whatever that principal can touch, with no second factor in the way.

2 credential types, both long-lived by default

An app registration carries keyCredentials (certificates) and passwordCredentials (client secrets). The portal will happily hand you a secret valid for 24 months, the longest it offers, and reliably the one everyone clicks. Nobody sets a reminder for two years out. So the secret outlives the project, the engineer who made it, and frequently the ticket that justified it. Pull both and sort by expiry:

audit.ps1PowerShell
Connect-MgGraph -Scopes 'Application.Read.All' -NoWelcome
​
try {
    $apps = Get-MgApplication -All -ErrorAction Stop
}
catch {
    Write-Error "Graph query failed: $($_.Exception.Message)"; return
}
​
$report = foreach ($app in $apps) {
    foreach ($cred in @($app.PasswordCredentials + $app.KeyCredentials)) {
        [pscustomobject]@{
            AppName    = $app.DisplayName
            AppId      = $app.AppId
            CredType   = if ($cred.Key) { 'Certificate' } else { 'Secret' }
            ExpiresUtc = $cred.EndDateTime
            DaysLeft   = [int]([math]::Floor(($cred.EndDateTime - (Get-Date)).TotalDays))
        }
    }
}
​
$report | Sort-Object DaysLeft | Format-Table -AutoSize

Read-only, paginated with -All, and it fails loudly instead of silently. Run it and watch how many rows show a negative DaysLeft that still, somehow, work.

The count you actually can’t guess

Don’t trust your gut on how many service principals you have, pull the number. In most tenants it dwarfs the human headcount, because every enterprise app, every consented multi-tenant SaaS tool, and every automation adds one. Each is a login that can hold app-role assignments:

audit.ps1PowerShell
$sp = Get-MgServicePrincipal -All -ErrorAction Stop
"Service principals in tenant: {0}" -f $sp.Count
​
# App-role (application permission) grants per principal, the over-provisioning map
foreach ($p in $sp) {
    $roles = Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $p.Id -All -ErrorAction SilentlyContinue
    if ($roles) {
        [pscustomobject]@{
            Name        = $p.DisplayName
            Id          = $p.Id
            GrantCount  = $roles.Count
        }
    }
} | Sort-Object GrantCount -Descending | Select-Object -First 25 | Format-Table -AutoSize

The top of that list is your blast radius. Application permissions apply tenant-wide with no user context to narrow them, a Mail.ReadWrite grant on a service principal reads everyone’s mail, not one mailbox. Export it, hand the top 25 to whoever owns each app, and make them justify the grant. Don’t revoke from a live query. Review first, then act on the reviewed file.

Managed identities: the credential you never rotate because there isn’t one

Here’s the one unambiguous win. A managed identity has no secret and no certificate, Azure handles the token lifecycle, rotation happens for you, and there’s nothing to leak into a repo. Where the workload runs on Azure, managed identity beats a client secret every single time. The secret-based app registration isn’t dead, but for anything that can use a managed identity, it should be.

The new NHIs nobody put on the spreadsheet

AI agents are identities too. They authenticate, they hold permissions, they call Graph, and they’re being spun up faster than anyone’s updating the inventory. Treat them exactly like any other service principal: scoped permissions, short-lived credentials, and a line in the sign-in logs you actually read. Filter Get-MgAuditLogSignIn for service-principal sign-ins and look for the principal that authenticates from a region it has no business being in.

Audit the credentials. Kill the two-year secrets. Move what you can to managed identities. Do it before the 2 a.m. page tells you which one you missed.