In this module
IAM0.4 Non-Human Identity — The Governance Gap Nobody Sees
IAM0.1 through IAM0.3 focused on human identities — member accounts, guest accounts, group memberships, permission creep, access reviews. This section turns to the identities most tenants don't govern at all: service principals, app registrations, managed identities, and the emerging AI agent model. These identities outnumber humans in most tenants, hold broader permissions, authenticate without MFA, and receive zero lifecycle governance. You'll run a non-human identity census against your own tenant and see the gap firsthand.
The identities nobody manages
Your tenant has 810 human identities. IAM0.1 through IAM0.3 measured their governance gaps — missing attributes, stale accounts, permission creep, rubber-stamp reviews. Those gaps are serious. But they exist within a lifecycle that at least has a creation event (someone creates the account) and a removal event (someone eventually disables it). Non-human identities often have neither.
A service principal is created when a developer registers an application or when an admin consents to a third-party app. A client secret is generated. API permissions are granted — sometimes User.Read.All, sometimes Mail.ReadWrite, sometimes Directory.ReadWrite.All. The integration works. The developer moves on. The service principal persists with its permissions and credentials, governed by nobody, reviewed by nobody, and invisible to the access review program that only evaluates human identities.
In most M365 tenants, non-human identities outnumber humans. Your tenant has 810 users. How many app registrations does it have? How many service principals? How many of those have credentials, and how many of those credentials have expired? You're about to find out.
Estimated time: 55 minutes.
Figure IAM0.4 — The governance asymmetry. Human identities have at least a partial lifecycle. Non-human identities — app registrations, service principals, managed identities, and AI agents — are created, credentialed, permissioned, and forgotten. They outnumber humans and receive zero governance in most tenants.
Your non-human identity census
Nobody owns the app registration lifecycle. A developer creates an app registration for a proof-of-concept, generates a client secret, grants API permissions, and moves on. The PoC ends. The app registration survives with its permissions and credentials. Six months later, the developer leaves. The app registration is now orphaned — active credentials, tenant-wide permissions, no owner, no review, no decommissioning date.
Entra Admin Center
Identity → Applications → App registrations → All applications
This page lists every app registration in your tenant. Count the total. Click any app → Certificates & secrets to see its credentials — client secrets with expiry dates and certificates. Click API permissions to see what Graph API permissions the app has been granted. Click Owners to see who's accountable (often empty). The portal gives you a per-app view. The PowerShell census below gives you the tenant-wide picture.
Connect to Microsoft Graph with the scopes needed for application and service principal queries:
Connect-MgGraph -Scopes "Application.Read.All", "Directory.Read.All"Start with app registrations — applications registered in your tenant:
$apps = Get-MgApplication -All -Property id, displayName, appId,
createdDateTime, passwordCredentials, keyCredentials,
requiredResourceAccess, signInAudience
Write-Host "=== APP REGISTRATION CENSUS ==="
Write-Host "Total app registrations: $($apps.Count)"
$withSecrets = ($apps | Where-Object { $_.PasswordCredentials.Count -gt 0 }).Count
$withCerts = ($apps | Where-Object { $_.KeyCredentials.Count -gt 0 }).Count
$withPerms = ($apps | Where-Object { $_.RequiredResourceAccess.Count -gt 0 }).Count
$noCredentials = ($apps | Where-Object {
$_.PasswordCredentials.Count -eq 0 -and $_.KeyCredentials.Count -eq 0
}).Count
Write-Host " With client secrets: $withSecrets"
Write-Host " With certificates: $withCerts"
Write-Host " With API permissions: $withPerms"
Write-Host " No credentials at all: $noCredentials"=== APP REGISTRATION CENSUS ===
Total app registrations: 347
With client secrets: 78
With certificates: 11
With API permissions: 289
No credentials at all: 258Three hundred and forty-seven app registrations. Compare that to your 810 human identities. In many production tenants the ratio is worse — organizations with 500 employees routinely have 400+ app registrations because every SaaS integration, every developer project, every proof-of-concept, and every Azure resource that needs an identity creates one.
78 registrations have client secrets — stored passwords that authenticate the application. 11 have certificates. 258 have no credentials, which means they're either unused, authenticate through a different mechanism (federated credentials, managed identity), or were created and abandoned. 289 have API permissions requested, which means they've declared what access they need — though declared permissions and granted permissions aren't always the same thing.
Now query service principals — the tenant-side representation of applications. Every app registration creates a service principal in the home tenant. Third-party apps that users consent to also create service principals without a corresponding app registration in your tenant:
$sps = Get-MgServicePrincipal -All -Property id, displayName, appId,
servicePrincipalType, appOwnerOrganizationId, createdDateTime,
signInActivity
$firstParty = ($sps | Where-Object {
$_.AppOwnerOrganizationId -eq "f8cdef31-a31e-4b4a-93e4-5f571e91255a"
}).Count
$yourOrg = ($sps | Where-Object {
$_.ServicePrincipalType -eq "Application" -and
$_.AppOwnerOrganizationId -ne "f8cdef31-a31e-4b4a-93e4-5f571e91255a"
}).Count
$thirdParty = ($sps | Where-Object {
$_.ServicePrincipalType -eq "Application" -and
$_.AppOwnerOrganizationId -and
$_.AppOwnerOrganizationId -ne "f8cdef31-a31e-4b4a-93e4-5f571e91255a" -and
$_.AppOwnerOrganizationId -ne (Get-MgOrganization).Id
}).Count
Write-Host "`n=== SERVICE PRINCIPAL CENSUS ==="
Write-Host "Total service principals: $($sps.Count)"
Write-Host " Microsoft first-party: $firstParty"
Write-Host " Your organization: $yourOrg"
Write-Host " Third-party vendors: $thirdParty"=== SERVICE PRINCIPAL CENSUS ===
Total service principals: 1,247
Microsoft first-party: 891
Your organization: 198
Third-party vendors: 1581,247 service principals. The majority (891) are Microsoft first-party — created automatically when you enable M365 services. You don't manage their credentials and Microsoft handles their lifecycle. The governance concern is with the other 356: 198 from your organization's app registrations and 158 from third-party vendors whose apps your users or admins consented to.
Those 158 third-party service principals are the ones most organizations can't account for. Each one was created when someone clicked "Accept" on an OAuth consent prompt. The service principal received whatever permissions the consent prompt requested. Nobody tracked which user consented, whether the permissions were appropriate, or whether the application still needs access.
At Northgate Engineering: Rachel Okafor runs the service principal census and finds 1,247 total. She can account for the Microsoft first-party apps. She recognizes about 60 of the 198 organizational apps — the ones her team built or uses daily. The other 138 organizational apps and all 158 third-party apps are unknown to her. When she asks Phil Greaves, he recognizes some from vendor evaluations and proof-of-concept projects that ended months ago. The service principals survived. Their permissions survived. Nobody decommissioned them because nobody owns the lifecycle.
Credential age — the secrets nobody rotates
App registrations authenticate using client secrets (passwords) or certificates. Both expire. When a credential expires, one of two things happens: someone rotates it (creates a new credential, updates the application, removes the old one) or nobody notices until the integration breaks at 2 AM on a Saturday.
Entra Admin Center
Identity → Applications → App registrations → select any app → Certificates & secrets
The Client secrets tab shows each secret's description, expiry date, and status. A red expiry date means the secret has expired. The Certificates tab shows the same for certificate credentials. Check 3–4 app registrations — particularly older ones. Count how many have expired secrets. The portal shows credential health per app. The PowerShell query below gives you the tenant-wide picture.
Query the credential health of your app registrations:
$now = Get-Date
$credentialHealth = $apps | Where-Object {
$_.PasswordCredentials.Count -gt 0 -or $_.KeyCredentials.Count -gt 0
} | ForEach-Object {
$secrets = $_.PasswordCredentials | ForEach-Object {
$daysToExpiry = ($_.EndDateTime - $now).Days
[PSCustomObject]@{
Type = "Secret"
Expiry = $_.EndDateTime.ToString("yyyy-MM-dd")
Days = $daysToExpiry
Status = if ($daysToExpiry -lt 0) { "EXPIRED" }
elseif ($daysToExpiry -lt 30) { "CRITICAL" }
elseif ($daysToExpiry -lt 90) { "WARNING" }
else { "OK" }
}
}
$certs = $_.KeyCredentials | ForEach-Object {
$daysToExpiry = ($_.EndDateTime - $now).Days
[PSCustomObject]@{
Type = "Certificate"
Expiry = $_.EndDateTime.ToString("yyyy-MM-dd")
Days = $daysToExpiry
Status = if ($daysToExpiry -lt 0) { "EXPIRED" }
elseif ($daysToExpiry -lt 30) { "CRITICAL" }
elseif ($daysToExpiry -lt 90) { "WARNING" }
else { "OK" }
}
}
$allCreds = @($secrets) + @($certs) | Where-Object { $_ }
$worst = ($allCreds | Sort-Object Days | Select-Object -First 1).Status
[PSCustomObject]@{
App = $_.DisplayName
Credentials = $allCreds.Count
WorstStatus = $worst
NearestExpiry = ($allCreds | Sort-Object Days | Select-Object -First 1).Expiry
}
}
$expired = ($credentialHealth | Where-Object { $_.WorstStatus -eq "EXPIRED" }).Count
$critical = ($credentialHealth | Where-Object { $_.WorstStatus -eq "CRITICAL" }).Count
$warning = ($credentialHealth | Where-Object { $_.WorstStatus -eq "WARNING" }).Count
Write-Host "=== CREDENTIAL HEALTH ==="
Write-Host "Apps with credentials: $($credentialHealth.Count)"
Write-Host " EXPIRED: $expired"
Write-Host " CRITICAL (< 30 days): $critical"
Write-Host " WARNING (< 90 days): $warning"
$credentialHealth | Where-Object { $_.WorstStatus -in "EXPIRED", "CRITICAL" } |
Sort-Object NearestExpiry |
Format-Table App, Credentials, WorstStatus, NearestExpiry -AutoSize=== CREDENTIAL HEALTH ===
Apps with credentials: 89
EXPIRED: 31
CRITICAL (< 30 days): 7
WARNING (< 90 days): 12
App Credentials WorstStatus NearestExpiry
--- ----------- ----------- -------------
Legacy CRM Connector 2 EXPIRED 2025-03-14
Vendor Reporting Tool 1 EXPIRED 2025-06-20
svc-data-pipeline 1 EXPIRED 2025-08-01
Dev Test App 1 EXPIRED 2025-09-12
...Thirty-one app registrations with expired credentials. Those credentials can no longer authenticate — but the app registrations still exist, the permissions are still granted, and someone could generate a new secret and immediately gain the access the original developer had. Seven more are within 30 days of expiry. Nobody is monitoring expiry dates because no credential governance process exists.
The expired credentials tell a governance story. Legacy CRM Connector expired in March 2025 — over a year ago. If the integration still worked, someone created a new credential outside the app registration (perhaps hardcoded in a script). If it stopped working, either nobody noticed or someone fixed it without cleaning up the expired credential. Either outcome is a governance failure.
Permission scope — what non-human identities can access
Credentials get the identity in the door. Permissions determine what it can do once inside. Query the API permissions granted to your organization's app registrations:
$graphAppId = "00000003-0000-0000-c000-000000000000" # Microsoft Graph
$permissionAudit = $apps | Where-Object {
$_.RequiredResourceAccess | Where-Object { $_.ResourceAppId -eq $graphAppId }
} | ForEach-Object {
$graphPerms = ($_.RequiredResourceAccess |
Where-Object { $_.ResourceAppId -eq $graphAppId }).ResourceAccess
$appPerms = ($graphPerms | Where-Object { $_.Type -eq "Role" }).Count
$delegatedPerms = ($graphPerms | Where-Object { $_.Type -eq "Scope" }).Count
[PSCustomObject]@{
App = $_.DisplayName
AppPermissions = $appPerms
Delegated = $delegatedPerms
Total = $appPerms + $delegatedPerms
}
} | Sort-Object AppPermissions -Descending
Write-Host "=== GRAPH API PERMISSION AUDIT ==="
Write-Host "Apps requesting Graph permissions: $($permissionAudit.Count)"
$permissionAudit | Select-Object -First 10 | Format-Table -AutoSize=== GRAPH API PERMISSION AUDIT ===
Apps requesting Graph permissions: 167
App AppPermissions Delegated Total
--- -------------- --------- -----
Data Migration Tool 12 0 12
svc-user-provisioning 8 0 8
Vendor SSO Connector 6 3 9
Legacy Reporting 5 2 7
Dev Test App 4 0 4
...AppPermissions (application permissions, also called "Role") are the dangerous ones. Delegated permissions act on behalf of a signed-in user and are constrained by that user's access. Application permissions act as the application itself — no user context, no user-level constraints. An app with Mail.ReadWrite as an application permission can read every mailbox in the tenant. An app with Directory.ReadWrite.All can modify any object in Entra ID.
Data Migration Tool holds 12 application permissions. Was that a one-time migration that completed months ago? Are those permissions still needed? Is the tool still running? The app registration doesn't tell you. The credential may be expired (check the credential health output). The permissions persist regardless of credential state — they're ready to use the moment someone generates a new secret.
Now identify the highest-risk permissions — the ones that grant tenant-wide read or write access:
$highRiskPerms = @(
"User.ReadWrite.All", "Directory.ReadWrite.All", "Mail.ReadWrite",
"Mail.Read", "Files.ReadWrite.All", "Sites.ReadWrite.All",
"Group.ReadWrite.All", "RoleManagement.ReadWrite.Directory"
)
$graphSP = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'"
$graphRoles = $graphSP.AppRoles
$apps | ForEach-Object {
$graphAccess = $_.RequiredResourceAccess |
Where-Object { $_.ResourceAppId -eq $graphAppId }
if ($graphAccess) {
$appRoles = $graphAccess.ResourceAccess |
Where-Object { $_.Type -eq "Role" }
$roleNames = $appRoles | ForEach-Object {
$roleId = $_.Id
($graphRoles | Where-Object { $_.Id -eq $roleId }).Value
}
$risky = $roleNames | Where-Object { $_ -in $highRiskPerms }
if ($risky) {
[PSCustomObject]@{
App = $_.DisplayName
HighRisk = ($risky -join ", ")
Count = $risky.Count
}
}
}
} | Sort-Object Count -Descending | Format-Table -AutoSizeApp HighRisk Count
--- -------- -----
Data Migration Tool User.ReadWrite.All, Group.ReadWrite.All, ... 5
svc-user-provisioning User.ReadWrite.All, Group.ReadWrite.All 2
Vendor Reporting Mail.Read, Sites.ReadWrite.All 2
Legacy Reporting Files.ReadWrite.All 1Each of these apps can do exactly what the permission name says — at the tenant level, without a user context, without MFA. Mail.Read as an application permission means the app can read every user's mailbox. Not one user's mailbox. Every mailbox. That's the permission a developer requested during a proof of concept and nobody scoped down afterward.
Before you move on, apply the three diagnostic questions from IAM0.1 to one of your high-permission app registrations. Why does this app have these permissions? Was the permission scope deliberately chosen, or did the developer request everything the API offered? When were these permissions last reviewed? Has anyone verified that the app still needs Directory.ReadWrite.All since the migration completed? Who is accountable for this app registration? If the developer who created it left the organization, who owns the lifecycle now?
At Northgate Engineering: Rachel Okafor runs the permission audit and finds
Data Migration Toolwith 12 application permissions includingUser.ReadWrite.All,Group.ReadWrite.All, andMail.ReadWrite. The migration completed 11 months ago. The app registration still exists. The credentials expired 6 months ago — but the permissions remain. She asks Phil Greaves who owns it. He says the contractor who built the migration tool left 8 months ago. Nobody reassigned ownership. Nobody revoked the permissions. The app registration sits in the tenant like an unlocked service entrance — the key doesn't currently work (expired credential), but the door is still propped open (permissions still granted), and cutting a new key takes 30 seconds (generate a new client secret).
The AI agent frontier
Beyond service principals and managed identities, a new category of non-human identity is emerging. Microsoft launched Entra Agent ID in preview in March 2026, alongside Agent 365 licensing. AI agents — Copilot agents, custom agents built on Azure AI, and third-party agents — now have their own identity model in Entra ID.
Agent identities differ from service principals in ways that change the governance model. An agent acts autonomously — it makes decisions about which data to access based on its instructions, not a predefined API call. An agent's permission scope is harder to predict because the agent decides at runtime which Graph API endpoints to call. An agent can be granted permissions through entitlement management, but the agent's actual data access patterns depend on its instructions, the user's context, and the model's reasoning — not a static configuration.
The governance questions for AI agents are the same three diagnostic questions, plus a fourth: What can this agent actually do with its permissions? A service principal with Mail.ReadWrite reads and writes mail in a predictable pattern defined by its code. An AI agent with the same permission reads and writes mail based on instructions it receives from users, other agents, or its own reasoning chain. The permission is the same. The risk surface is different.
Module 11 builds the governance framework for AI agent identities — registration governance, blueprint-based policy templates, sponsor accountability, permission boundaries, and lifecycle management. The framework doesn't exist yet in most organizations because the identity model is new. You'll build it.
If your organization already uses Copilot agents, custom AI agents, or is evaluating third-party agent platforms, the governance problem is already present — even if Entra Agent ID isn't fully deployed. Those agents authenticate through service principals or user-delegated tokens today. They appear in your service principal census alongside every other application. The difference is that their access patterns are unpredictable by design — the model decides what to query at runtime, not a developer at build time. A service principal running a nightly data sync touches the same endpoints every execution. An AI agent responding to user prompts touches different endpoints every time, limited only by its granted permissions. The governance implication: you can't monitor agent behavior by comparing it to a known baseline. You need permission boundaries that constrain what the agent can do, not just logging that records what it did.
The governance gap summarized
Run the full non-human identity count against your human identity count:
$humanCount = (Get-MgUser -All | Where-Object { $_.UserType -eq "Member" }).Count
$guestCount = (Get-MgUser -All | Where-Object { $_.UserType -eq "Guest" }).Count
$appCount = (Get-MgApplication -All).Count
$spCount = (Get-MgServicePrincipal -All).Count
Write-Host "=== IDENTITY RATIO ==="
Write-Host "Human members: $humanCount"
Write-Host "Guests: $guestCount"
Write-Host "App registrations: $appCount"
Write-Host "Service principals: $spCount"
Write-Host "Non-human total: $($appCount + $spCount)"
Write-Host "Ratio: $([math]::Round(($appCount + $spCount) / $humanCount, 1)):1 non-human to human"=== IDENTITY RATIO ===
Human members: 810
Guests: 23
App registrations: 347
Service principals: 1247
Non-human total: 1594
Ratio: 2.0:1 non-human to humanTwo non-human identities for every human. The human identities have at least a partial lifecycle — creation, some assignment governance, maybe an annual review. The non-human identities have creation and nothing else. No access reviews. No periodic certification. No owner accountability. No credential rotation policy. No lifecycle automation.
That asymmetry is the governance gap Modules 9 through 11 close. By Module 11, every non-human identity type — service principals, managed identities, and AI agents — has a governance framework with inventory, classification, credential policies, permission right-sizing, owner accountability, and review cadences.
Reusable script — the non-human identity census from this section:
# IAM0.4 — Non-Human Identity Census
Connect-MgGraph -Scopes "Application.Read.All", "Directory.Read.All"
# App registration census
$apps = Get-MgApplication -All -Property id, displayName, appId,
createdDateTime, passwordCredentials, keyCredentials, requiredResourceAccess
Write-Host "=== APP REGISTRATION CENSUS ==="
Write-Host "Total: $($apps.Count)"
Write-Host " With secrets: $(($apps | Where-Object { $_.PasswordCredentials.Count -gt 0 }).Count)"
Write-Host " With certificates: $(($apps | Where-Object { $_.KeyCredentials.Count -gt 0 }).Count)"
Write-Host " With permissions: $(($apps | Where-Object { $_.RequiredResourceAccess.Count -gt 0 }).Count)"
# Credential health
$now = Get-Date
$expired = 0; $critical = 0; $warning = 0
$apps | Where-Object { $_.PasswordCredentials.Count -gt 0 -or $_.KeyCredentials.Count -gt 0 } |
ForEach-Object {
$allCreds = @($_.PasswordCredentials) + @($_.KeyCredentials)
$allCreds | ForEach-Object {
$days = ($_.EndDateTime - $now).Days
if ($days -lt 0) { $script:expired++ }
elseif ($days -lt 30) { $script:critical++ }
elseif ($days -lt 90) { $script:warning++ }
}
}
Write-Host "`n=== CREDENTIAL HEALTH ==="
Write-Host " Expired credentials: $expired"
Write-Host " Critical (< 30 days): $critical"
Write-Host " Warning (< 90 days): $warning"
# Service principal census
$sps = Get-MgServicePrincipal -All -Property id, displayName,
servicePrincipalType, appOwnerOrganizationId
$msft = ($sps | Where-Object {
$_.AppOwnerOrganizationId -eq "f8cdef31-a31e-4b4a-93e4-5f571e91255a"
}).Count
Write-Host "`n=== SERVICE PRINCIPAL CENSUS ==="
Write-Host "Total: $($sps.Count)"
Write-Host " Microsoft first-party: $msft"
Write-Host " Other: $($sps.Count - $msft)"
# Identity ratio
$humanCount = (Get-MgUser -All | Where-Object { $_.UserType -eq "Member" }).Count
Write-Host "`n=== IDENTITY RATIO ==="
Write-Host "Human: $humanCount | Non-human: $($apps.Count + $sps.Count)"
Write-Host "Ratio: $([math]::Round(($apps.Count + $sps.Count) / $humanCount, 1)):1"Decision-point simulation
Scenario 1. Your app registration audit reveals 347 registrations. 89 have active credentials. 31 of those 89 have credentials that expired more than 6 months ago but the app registration still exists. What's the governance risk of expired credentials on active app registrations?
Expired credentials mean the app can't authenticate — but the permissions are still granted. If someone creates a new credential on that app registration (and anyone with Application Administrator or the app's owner role can do this), the app immediately regains all its previously granted permissions. The 31 registrations with expired credentials are dormant attack surfaces: the permissions persist silently, waiting for a new credential. The remediation: review all 31 with their owners. If the app is no longer needed, delete the registration (which removes the service principal and all permissions). If the app is needed, rotate the credential and document the lifecycle.
Scenario 2. A developer creates an app registration for a CI/CD pipeline and grants it Directory.ReadWrite.All — the broadest directory permission available. The app works. The developer moves to another team. Nobody else knows the app exists. What governance controls would have prevented this?
Three controls: consent policy (admin consent required for high-privilege permissions like Directory.ReadWrite.All), app registration ownership governance (every registration must have at least two owners), and credential expiry enforcement (maximum 6-month credential lifetime forces periodic review). Without these, the app becomes an orphaned identity with domain-admin-equivalent permissions and no accountable owner. Module 7 builds the service principal governance framework that prevents this pattern.
Scenario 3. Your tenant has 15 managed identities (system-assigned) on Azure resources. Nobody has inventoried what permissions they hold. Your colleague argues that managed identities are "safe" because they have no credentials to leak. Is that correct?
Partially. Managed identities eliminate credential leakage — there's no secret or certificate to steal. But they still hold RBAC permissions, and over-provisioned managed identities are just as dangerous as over-provisioned service principals. A managed identity with Contributor on a subscription can modify any resource in that subscription. The "no credentials" argument addresses one threat vector (credential theft) but ignores another (permission scope). Module 8 covers managed identity permission auditing and right-sizing.
You're reading the free modules of Identity and Access Management in Microsoft 365
The full course continues with advanced topics, production detection rules, worked investigation scenarios, and deployable artifacts.