We spent a decade hardening human sign-ins - MFA, phishing-resistant credentials, risk-based Conditional Access - and quietly left the non-human half of the directory wide open. Every Entra tenant carries hundreds of workload identities: SaaS connectors, CI/CD deployers, automation runbooks, daemon services, the random app some team consented to in 2021 and forgot. Most hold a client secret valid for one or two years, broad Microsoft Graph application permissions, and zero conditions on where they can authenticate from. They do not get MFA prompts. They do not raise a “new device” notification. They do not reset their own password when your SOC resets everyone else’s. That combination is exactly why attackers love them: a stolen service-principal secret is a quiet, durable foothold that survives the very incident-response actions you take against human accounts.
This guide treats workload identities as first-class subjects of governance, focused on the Entra protection and governance side - the directory-level controls - rather than the pipeline plumbing. (For the CI/CD implementation of federation, see Secretless CI/CD: Workload Identity Federation for GitHub Actions and AKS; this article is its “how do I protect and govern the identities themselves” companion.) We will inventory the estate, put Conditional Access around service principals, watch them with Identity Protection, strip the secrets out on a disciplined credential ladder, right-size their Graph permissions, wire the logs into detections, and finish with the compromise playbook. Along the way we correct a dangerous myth - that disabling a compromised service principal instantly cuts its access. It does not, and knowing why is the difference between a contained incident and a data-exfiltration post-mortem.
Licensing note up front: workload-identity Conditional Access and workload-identity risk detections both require Microsoft Entra Workload ID Premium, a per-service-principal add-on that is separate from the user-based Entra ID P2 your humans consume. You license the specific service principals you want to govern, not the whole tenant. Budget for it before you design around it - the two highest-leverage controls in this article both sit behind that SKU.
What problem this solves
The pain is concrete and it is almost always silent. A CI deployer’s client secret was pasted into an old pipeline log, or committed to a repo, or handed to a contractor who left. Twelve months later it still authenticates. An attacker who finds it does not need your VPN, your MFA, or a phished user - they present the secret to the token endpoint from anywhere on the internet and receive an app-only access token carrying whatever Graph application permissions that app was granted. If the app holds Mail.Read (application), that is not one mailbox - it is every mailbox in the tenant. If it holds Directory.ReadWrite.All or AppRoleAssignment.ReadWrite.All, it is effectively tenant takeover. No user was involved, so none of your user-focused controls fired.
What breaks without this discipline: you cannot answer the questions an auditor or an incident commander will ask. Which apps can read all mail? Which secrets expire this month? Which service principals have signed in from a country we do not operate in? Which app had a second credential quietly added last Tuesday? Teams that have not inventoried and instrumented their workload identities answer all of these with a shrug, then discover during an incident that the blast radius was enormous and the dwell time was months. The real intrusions of the last few years - the Midnight Blizzard-class attacks - followed exactly this pattern: abuse an over-privileged OAuth application, then use Graph to read mail and add a second credential for durable persistence.
Who hits this: every organisation with a non-trivial Entra tenant. It bites hardest where app sprawl is highest - SaaS-heavy environments, multi-team platform tenants, and anyone who has run Microsoft 365 long enough to accumulate a decade of consented apps. The fix is not a product you buy and forget; it is the same Zero Trust posture you already apply to people, retargeted at workloads: verify the context of every sign-in, grant least privilege, assume the credential will leak, and delete the credential entirely wherever you can.
To frame the whole field before the deep dive, here is the workload-identity control plane end to end - each layer, what it defends, and the SKU it needs:
| Control layer | What it does | Primary mechanism | Licensing |
|---|---|---|---|
| Inventory & hygiene | Know every app, credential, owner, and last sign-in | Graph PowerShell / az; scheduled reports |
Free (Graph) |
| Conditional Access (location) | Block SP sign-ins from unexpected IPs | Workload-identity CA policy, named location | Workload ID Premium |
| Conditional Access (risk) | Block SPs flagged at elevated risk | Workload-identity CA + servicePrincipalRiskLevels |
Workload ID Premium |
| Identity Protection | Detect leaked creds, anomalous SP activity | Risky workload identities, risk detections | Workload ID Premium |
| Secretless migration | Remove the credential entirely | Managed identity, federated credentials (FIC) | Free (platform) |
| Permission governance | Least-privilege Graph scopes, no ad-hoc consent | App-role review, consent policies, app management policy | Free (Graph) |
| Monitoring & audit | Alert on credential-add, permission-grant, risk | Sign-in logs, audit logs, Graph activity logs, Sentinel | Log Analytics / Sentinel cost |
| Break-glass & response | Contain a compromised SP fast, keep an escape hatch | Disable, credential purge, hunt, rebuild | Free (process) |
Learning objectives
By the end of this article you can:
- Enumerate the full workload-identity attack surface - stolen secrets, over-privileged Graph application permissions, unused and orphaned apps - and explain why each is more dangerous than its human equivalent.
- Produce a complete inventory of app registrations and service principals with owners, credential expiry, last sign-in, and Tier-0 permission holders using Microsoft Graph PowerShell.
- Design and deploy workload-identity Conditional Access policies with location and risk conditions, understand precisely what the feature does and does not support, and know which service principals to license.
- Operate Identity Protection for workload identities: interpret each risk detection, query risky service principals, and close the loop with risk-based blocking.
- Rank credential options on the secretless ladder (managed identity > federated credential > certificate > secret) and migrate real workloads down it.
- Govern application permissions and consent: distinguish delegated from application permissions, scope high-impact grants, disable ad-hoc user consent, and cap secret lifetime with an app management policy.
- Wire service-principal sign-in and audit logs into detections that catch the classic persistence moves, and run a tested compromise playbook - including the correct handling of app-only tokens that cannot be revoked.
Prerequisites & where this fits
You should already understand the Entra application model: an app registration is the definition of an application (its identity, credentials, and requested permissions), and a service principal (also called an enterprise application in the portal) is the instance of that application inside a specific tenant, where sign-ins, consent, and role assignments actually live. You should be comfortable with Microsoft Graph PowerShell (Connect-MgGraph, the Microsoft.Graph.* modules) and the az ad CLI, know how to read JSON output, and understand the difference between delegated permissions (act as a signed-in user) and application permissions (act as the app itself, tenant-wide). Familiarity with Conditional Access for users, and with OAuth 2.0 client-credentials and OIDC token exchange, will make the secretless section land faster.
This sits in the Identity Security track and is deliberately the protection and governance counterpart to the implementation-focused federation article. It assumes the managed-identity mechanics covered in Managed Identities Deep Dive: User-Assigned Identities, Federated Credentials, and RBAC Patterns for Azure Workloads and the user-side risk engine from Operationalizing Entra ID Protection: Risk-Based Conditional Access, Detection Tuning, and Risk Investigation. It pairs with the consent-governance depth in Governing OAuth Consent and Application Permissions in Entra ID: Stopping Illicit Consent and Hardening App Trust, the CA design patterns in Designing Conditional Access at Scale: A Persona-Based Policy Framework with Authentication Context and Filters, and the human-side foundation in Zero Trust on Microsoft Entra: Conditional Access + PIM, Step by Step.
Here is who owns what during a workload-identity incident, so you page the right person fast:
| Concern | What lives here | Who usually owns it | Failure it causes |
|---|---|---|---|
| App registration & credentials | Secrets, certs, FICs, requested permissions | App/dev team | Leaked secret, silent second credential added |
| Service principal & consent | Granted app roles, sign-ins, ownership | Identity/security team | Over-privileged tenant-wide access |
| Conditional Access (workload) | Location/risk policies for SPs | Identity/security team | Stolen secret usable from anywhere |
| Identity Protection | Risk detections, risky SP state | Security operations | Compromise undetected |
| Azure RBAC / managed identities | Data-plane and control-plane roles in Azure | Platform team | Over-scoped managed identity |
| Log Analytics / Sentinel | Sign-in, audit, Graph activity, risk tables | SOC / detection engineering | No alert on persistence moves |
Core concepts
Six mental models make every later decision obvious.
An app has two halves, and they live in different places. The app registration (Graph: application) is the globally-unique definition - it holds the client secrets, certificates, federated credentials, and the requested permissions. The service principal (Graph: servicePrincipal) is the tenant-local instance - it holds the granted permissions (consent), the role assignments, the sign-in activity, and the accountEnabled flag. When you “delete a secret” you edit the registration; when you “disable the app” or “revoke consent” you edit the service principal. Governance touches both halves, and confusing them is the most common source of “I fixed it but it still works.”
A credential is a bearer secret with a long life and no second factor. A client secret or certificate authenticates the application from anywhere on the internet. There is nothing to phish-then-fail, no device to be non-compliant, no location the platform inherently distrusts. Default secret lifetimes are measured in months to years. This is the property that makes a leaked workload credential categorically worse than a leaked user password: the password is protected by MFA and CA; the raw secret, by default, is protected by nothing but obscurity.
Application permissions are tenant-wide and silent. A delegated permission is scoped to whatever the signed-in user can already do. An application permission (app role) like Mail.Read or Directory.ReadWrite.All applies across the entire tenant with no user to constrain it. If an attacker controls a principal holding Mail.Read (application), they read every mailbox in the organisation, not one. A small set of application permissions - the ones that let an app grant itself more permissions or assign directory roles - are effectively tenant-takeover primitives and must be treated as Tier 0.
App-only tokens cannot be revoked before they expire. This is the myth-buster. When you disable a service principal (accountEnabled = false) or delete its credentials, you stop new token issuance - but any app-only access token already issued remains valid until it expires, typically ~60-90 minutes. There is no revokeSignInSessions action for service principals the way there is for users. Containment therefore has two horizons: cut future issuance immediately (disable + block via CA), and wait out or break the resource trust for the tokens already in flight. Continuous Access Evaluation (CAE) shortens this window for the subset of resources that support it, but you cannot assume instant cutoff.
Conditional Access for workloads is a different, narrower target. Human CA policies do not apply to service principals. There is a separate CA target - workload identities - and it supports a deliberately small set of conditions: block as the only grant control, plus location and service-principal risk conditions, for single-tenant service principals. There is no MFA (no human to prompt), no device compliance, no session control. The entire value proposition is “is this sign-in coming from where I expect, and is this principal currently risky?”
Secretless is a ladder, not a switch. Not every workload can go fully credential-free today, so you rank the options and climb: a managed identity (Azure-hosted, no credential you can leak) beats a federated identity credential (external workload exchanges a short-lived OIDC token, still no stored secret) beats a certificate (a credential, but harder to exfiltrate and easier to bind) beats a client secret (a bare bearer string, the worst option). You migrate workloads down the ladder as fast as their hosting allows, and you cap the bottom rung so no one creates a two-year secret again.
The vocabulary in one table
Pin down every moving part before the deep sections. The glossary repeats these for lookup; this table is the mental model side by side:
| Concept | One-line definition | Where it lives | Why it matters to security |
|---|---|---|---|
| App registration | The application’s global definition | Home tenant (application) |
Holds credentials + requested permissions |
| Service principal | The tenant-local instance of an app | Your tenant (servicePrincipal) |
Holds consent, roles, sign-ins, enabled flag |
| Managed identity | A service principal Azure manages the credential for | Your tenant + an Azure resource | No secret to leak; top of the ladder |
| Client secret | A bearer password on the app | App registration | Long-lived, exfiltratable; bottom of the ladder |
| Certificate | An asymmetric credential on the app | App registration | Harder to steal than a secret |
| Federated credential (FIC) | A trust to an external OIDC issuer | App registration | Secretless for off-Azure workloads |
| Application permission | Tenant-wide app role (acts as the app) | Consent on the SP | Blast radius is the whole tenant |
| Delegated permission | Scoped to a signed-in user | Consent on the SP | Bounded by user’s own rights |
| Workload-identity CA | Conditional Access targeting SPs | CA policy (clientApplications) |
Location/risk block; needs Workload ID Premium |
| Risky workload identity | An SP flagged by Identity Protection | riskyServicePrincipals |
Automated containment signal |
| App management policy | Restrictions on app credentials tenant-wide | Policy object | Caps secret lifetime, blocks new secrets |
| App-only token | Token issued via client credentials | Runtime | Cannot be revoked before expiry |
The workload-identity attack surface
Three properties turn the average enterprise’s service-principal estate into an attacker’s favourite persistence layer. Understand each, because the controls in the rest of the article map one-to-one onto them.
Stolen and leaked credentials
The credential is the identity. Anyone holding a valid client secret or certificate private key authenticates as the app with no further checks by default. Secrets leak through a depressingly predictable set of channels, and each has a corresponding control:
| Leak channel | How the secret escapes | Blast radius | Primary control |
|---|---|---|---|
| Source control | Secret committed to a repo (public or private) | Anyone with repo/history access | Secret scanning + go secretless |
| CI/CD logs | Secret echoed into build output | Anyone reading pipeline logs | Federated credentials (no stored secret) |
| Config files / images | .env, appsettings, baked container layers |
Anyone with the artifact | Managed identity / Key Vault reference |
| Departed staff / contractors | Secret copied before offboarding | The individual, indefinitely | Rotate on exit; prefer short-lived FIC |
| Shared password managers | Secret in a team vault, over-shared | Vault members | Least-share; secretless |
| Third-party/SaaS breach | Partner app’s stored copy of your secret | The partner’s attacker | Certificate or FIC; scoped permissions |
The defining characteristic across all six: the credential works from anywhere, silently, until it expires or is removed. That is what the location lock (Conditional Access) and the secretless ladder attack directly - one neutralises a stolen secret by rejecting the sign-in from an unexpected IP, the other removes the stealable thing entirely.
Over-privileged service principals
The second property is that application permissions are tenant-wide, so an over-scoped app is a pre-positioned blast radius. A tiny subset of Graph application permissions are not merely “high impact” - they are privilege-escalation primitives, because an app holding one can grant itself any other permission or assign itself a directory role. Treat these as Tier 0 and justify every single holder:
| Graph application permission | Why it is Tier 0 | Realistic escalation |
|---|---|---|
RoleManagement.ReadWrite.Directory |
Assign directory roles | Make itself (or a new SP) Global Administrator |
AppRoleAssignment.ReadWrite.All |
Grant app-role assignments | Grant itself Directory.ReadWrite.All, then anything |
Application.ReadWrite.All |
Modify any app/SP | Add a credential to a privileged app and use it |
Directory.ReadWrite.All |
Broad directory write | Modify group memberships, roles indirectly |
PrivilegedAccess.ReadWrite.AzureADGroup |
Manage PIM-for-groups | Elevate into role-assignable groups |
RoleManagement.ReadWrite.Exchange |
Assign Exchange roles | Grant mailbox/organization control |
Below Tier 0 sits a broad band of high-impact data permissions - the ones that read or write the crown jewels tenant-wide. These are not escalation primitives but they are catastrophic for confidentiality:
| Graph application permission | What it exposes | Least-privilege alternative |
|---|---|---|
Mail.Read / Mail.ReadWrite |
Every mailbox in the tenant | Scope with an Application Access Policy to one group |
Files.Read.All / Files.ReadWrite.All |
All SharePoint/OneDrive files | Sites.Selected (per-site consent) |
User.Read.All / User.ReadWrite.All |
All user profiles / write | Delegated, or a narrower scope |
Group.ReadWrite.All |
All groups and memberships | GroupMember.Read.All if read-only suffices |
Chat.Read.All / ChannelMessage.Read.All |
All Teams messages | Resource-specific consent (RSC) per team |
Sites.FullControl.All |
Full control of all SharePoint | Sites.Selected |
The pattern to internalise: almost every “all-tenant” application permission has a narrower, resource-scoped alternative - Sites.Selected instead of Files.ReadWrite.All, an Exchange Application Access Policy instead of tenant-wide Mail.*, resource-specific consent instead of Chat.Read.All. The governance section turns this into a workflow.
Unused, orphaned, and stale apps
The third property is entropy. Apps accumulate; they rarely get cleaned up. An app that no one owns, no one monitors, and no one has signed into for a year is pure liability - it still holds its credential and its permissions, and if it is compromised nobody will notice because nobody is watching. The hygiene table you will build in the next section flags exactly these:
| Staleness signal | Why it is dangerous | Disposition |
|---|---|---|
| No owner assigned | No one is accountable; changes go unnoticed | Assign an owner or retire |
| No sign-in for 90+ days | Likely unused but still exploitable | Disable, then delete after grace |
| Secret but never a certificate | Weakest credential type, often forgotten | Rotate to cert or go secretless |
| Multiple secrets, several expired | Credential churn / possible persistence | Audit each; remove all but the active one |
| Consented in bulk years ago | Permissions no one reviewed | Re-review consent; revoke unused roles |
| Single-tenant but multi-region sign-in | Possible credential sharing or theft | Investigate; apply location lock |
Finally, map the attacker’s kill-chain onto these three properties so your detections have a mental model. This is the workload-identity view of the classic persistence pattern:
| Attacker step | Technique (informal) | What it looks like in your logs | The control that breaks it |
|---|---|---|---|
| Obtain a credential | Find a leaked secret / phish an app admin | New IP authenticating a known SP | Location-lock CA; secretless |
| Authenticate from attacker infra | Client-credentials grant from a VPS | SP sign-in from an unexpected country | Location-lock CA; risk detection |
| Establish persistence | Add a second secret or a rogue FIC | “Add service principal credentials” audit event | Alert on credential-add; app mgmt policy |
| Escalate | Grant self more app roles | “Add app role assignment to SP” audit event | Tier-0 permission review; alert on grant |
| Exfiltrate | Read all mail / files via Graph | Spike in Graph activity, new resources touched | Least privilege; Graph activity monitoring |
| Evade | Blend into normal SP behaviour | Anomalous SP activity vs baseline | Identity Protection risk detection |
Inventory and hygiene: enumerate every app, secret, and permission
You cannot govern what you have not enumerated. Every program in this article starts from a complete, refreshed inventory of both halves of every app. Connect with the least scopes that let you read the estate:
Connect-MgGraph -Scopes "Application.Read.All","Directory.Read.All","AuditLog.Read.All"
App registrations with credential expiry
The registration is where credentials live, so start there - list every app with its secret and certificate counts and the next expiry, sorted so the imminently-expiring float to the top:
Get-MgApplication -All -Property DisplayName,AppId,PasswordCredentials,KeyCredentials |
Select-Object DisplayName, AppId,
@{n='Secrets'; e={ $_.PasswordCredentials.Count }},
@{n='Certs'; e={ $_.KeyCredentials.Count }},
@{n='NextExpiry'; e={
@($_.PasswordCredentials.EndDateTime + $_.KeyCredentials.EndDateTime) |
Where-Object { $_ } | Sort-Object | Select-Object -First 1 }} |
Sort-Object NextExpiry | Format-Table -AutoSize
Bucket the results and act by bucket - this is the hygiene triage table you re-run on a schedule:
| Expiry bucket | Action | Owner-notify? | Priority |
|---|---|---|---|
| Already expired | Confirm app is dead; remove credential or retire app | Yes | Clean up (low risk, high noise) |
| Expires < 7 days | Rotate now or app breaks; escalate | Yes, urgent | P1 |
| Expires 7-30 days | Schedule rotation; prefer secretless migration | Yes | P2 |
| Expires 30-90 days | Plan migration off secrets | Optional | P3 |
| No expiry set (cert with far date) | Verify intended; add to rotation calendar | Optional | P3 |
| Never had a credential | Likely managed identity or unused; verify | No | Info |
Service principals: owners, last sign-in, and Tier-0 permissions
The service principal is where consent and activity live. The single most valuable query surfaces your blast radius - which principals hold the dangerous Graph application permissions:
$graph = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'"
$tier0 = @('RoleManagement.ReadWrite.Directory','AppRoleAssignment.ReadWrite.All',
'Application.ReadWrite.All','Directory.ReadWrite.All')
$highImpact = @('Mail.Read','Mail.ReadWrite','Files.ReadWrite.All','User.ReadWrite.All',
'Group.ReadWrite.All','Sites.FullControl.All')
Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $graph.Id -All |
ForEach-Object {
$role = ($graph.AppRoles | Where-Object Id -eq $_.AppRoleId).Value
if ($role -in ($tier0 + $highImpact)) {
[pscustomobject]@{
App = $_.PrincipalDisplayName
Permission = $role
Tier = if ($role -in $tier0) {'TIER-0'} else {'High-impact'}
}
}
} | Sort-Object Tier, App | Format-Table -AutoSize
00000003-0000-0000-c000-000000000000is the well-known Microsoft Graph app ID - the same in every tenant. The query walks the app-role assignments granted to each principal on Graph and filters to the ones that hurt.
Now flag the orphans and stragglers. Apps with no owner are unaccountable; find them and their sign-in recency:
Get-MgServicePrincipal -All -Property Id,DisplayName,AppId,AccountEnabled,ServicePrincipalType |
Where-Object ServicePrincipalType -eq 'Application' |
ForEach-Object {
$owners = Get-MgServicePrincipalOwner -ServicePrincipalId $_.Id -ErrorAction SilentlyContinue
[pscustomobject]@{
App = $_.DisplayName
AppId = $_.AppId
Enabled = $_.AccountEnabled
OwnerCount = @($owners).Count
}
} | Where-Object OwnerCount -eq 0 | Sort-Object App | Format-Table -AutoSize
Service-principal sign-in activity (who is actually active) lives in the sign-in logs under the service-principal category. With Workload ID Premium and AuditLog.Read.All you can pull it directly:
Get-MgAuditLogSignIn -Filter "signInEventTypes/any(t: t eq 'servicePrincipal')" -Top 500 |
Group-Object -Property ServicePrincipalName |
Select-Object Name, Count,
@{n='LastSignIn'; e={ ($_.Group.CreatedDateTime | Sort-Object -Descending | Select-Object -First 1) }} |
Sort-Object LastSignIn | Format-Table -AutoSize
Turn the raw inventory into a scored hygiene report. Each row is a rule; the composite tells you what to retire first:
| Inventory field | Source | Hygiene rule | Flag when |
|---|---|---|---|
| Owner count | Get-MgServicePrincipalOwner |
Every app must have ≥1 owner | 0 owners |
| Last SP sign-in | Sign-in logs (SP category) | Active apps sign in regularly | > 90 days ago |
| Credential type | KeyCredentials vs PasswordCredentials |
Prefer cert or none | Secret present, no cert |
| Credential count | PasswordCredentials.Count |
Minimise active credentials | > 1 active secret |
| Next expiry | EndDateTime |
Rotate before break | < 30 days |
| Tier-0 permission | App-role assignments | Justify or remove | Any Tier-0 held |
| Publisher verified | verifiedPublisher |
Third-party apps should be verified | Unverified 3rd-party |
| Account enabled | accountEnabled |
Disable stale before delete | Enabled + idle 90d |
The disposition workflow for what the report surfaces - never hard-delete first, always disable-then-delete with a grace window so you can reverse a mistake:
| Finding | Immediate action | Follow-up | Grace before delete |
|---|---|---|---|
| Orphan + idle 90d | Disable (accountEnabled=false) |
Notify last-known owner/team | 30 days |
| Active but over-privileged | Review each Tier-0/high-impact grant | Remove unjustified app roles | N/A (keep app) |
| Expired credentials only | Remove the dead credentials | Confirm app still needed | 30 days if unused |
| Multiple secrets | Identify the one in use | Remove the rest | Immediate for extras |
| Unverified 3rd-party w/ broad scope | Restrict via consent policy | Contact vendor for scoping | Case by case |
Conditional Access for workload identities
The single highest-leverage control in this entire article is a location condition on service-principal sign-ins. The vast majority of your workload identities only ever authenticate from a fixed, knowable set of egress IPs - your CI runners, your automation subnet, a partner’s published ranges. Lock them to those IPs and a stolen secret used from an attacker’s infrastructure is simply rejected at the token endpoint. It neutralises the leaked-credential problem on day one, while the slower secretless migration catches up behind it.
What workload-identity CA does and does not support
Set expectations precisely, because this target is narrower than human CA and people design policies that silently do nothing:
| Capability | Supported for workload identities? | Note |
|---|---|---|
| Grant control: block | Yes | The only grant control available |
| Grant control: require MFA | No | No human to prompt |
| Grant control: require compliant device | No | Not applicable to an app |
| Condition: location | Yes | Named locations / IP ranges |
| Condition: service-principal risk | Yes (needs Identity Protection) | servicePrincipalRiskLevels |
| Condition: user/sign-in risk | No | Those are user-risk concepts |
| Target: single-tenant service principals | Yes | ServicePrincipalsInMyTenant or specific IDs |
| Target: multi-tenant / SaaS third-party SPs | No | Only your own single-tenant apps |
| Target: managed identities | Yes (with care) | Must include the platform’s egress ranges |
| Session controls | No | Not applicable |
Step 1 - define the legitimate egress as a named location
$ip = New-MgIdentityConditionalAccessNamedLocation -BodyParameter @{
"@odata.type" = "#microsoft.graph.ipNamedLocation"
displayName = "Corp egress + CI runners"
isTrusted = $true
ipRanges = @(
@{ "@odata.type" = "#microsoft.graph.iPv4CidrRange"; cidrAddress = "203.0.113.0/24" }
@{ "@odata.type" = "#microsoft.graph.iPv4CidrRange"; cidrAddress = "198.51.100.16/28" }
)
}
Step 2 - target service principals, block everything outside the location
Always start in report-only (enabledForReportingButNotEnforced) and read the results before enforcing - workload sign-ins are easy to under-estimate and you do not want to discover a shadow automation by breaking it:
$params = @{
displayName = "WL - Block SP sign-in outside corp egress"
state = "enabledForReportingButNotEnforced" # report-only first
conditions = @{
clientApplications = @{
includeServicePrincipals = @("ServicePrincipalsInMyTenant")
excludeServicePrincipals = @("<break-glass-automation-sp-object-id>")
}
applications = @{ includeApplications = @("All") }
locations = @{
includeLocations = @("All")
excludeLocations = @($ip.Id) # everything except our egress
}
}
grantControls = @{ operator = "OR"; builtInControls = @("block") }
}
New-MgIdentityConditionalAccessPolicy -BodyParameter $params
The conditions that matter, their values, and the gotcha for each:
| Condition | Values you set | Effect | Gotcha |
|---|---|---|---|
includeServicePrincipals |
ServicePrincipalsInMyTenant or object IDs |
Which SPs the policy governs | The string targets all single-tenant SPs at once |
excludeServicePrincipals |
Break-glass SP object ID(s) | Escape hatch | Forgetting this can lock you out of remediation |
includeLocations / excludeLocations |
Named location IDs / All |
Where sign-in is allowed | Exclude = allowed; include All + exclude egress = block-elsewhere |
applications.includeApplications |
All or specific resource app IDs |
Which target resources | Usually All for a broad egress lock |
servicePrincipalRiskLevels |
low/medium/high |
Risk-based block | Requires Identity Protection licensing |
grantControls.builtInControls |
block |
The action | block is the only meaningful control here |
Managed identities and the egress-range trap
A location lock on a managed identity used by an Azure PaaS service is legitimate, but you must include that service’s outbound ranges in the named location, not just your corporate egress - the managed identity authenticates from Azure’s infrastructure, not your office. Get this wrong and you break the very platform integration the identity exists for. The safe pattern is to scope MI location policies narrowly (specific MIs, specific service tags) rather than sweeping them into the corporate-egress block.
Break-glass exclusion - the same discipline as human accounts
Just as you exclude human break-glass accounts from user CA, you exclude one tightly-held emergency automation principal from the workload CA policies, so a bad IP list or a misfired risk detection cannot lock you out of your own remediation tooling. Keep exactly one, monitor it obsessively (alert on any sign-in), and rotate it on a schedule - the design mirrors the human pattern in Engineering Break-Glass Emergency Access Accounts in Entra ID: Exclusions, Hardening, and Tamper-Evident Monitoring.
Identity Protection risk for service principals
Entra Identity Protection extends to workload identities and raises risk detections that are precisely the signals an attacker trips. Requires Workload ID Premium on the licensed principals. The detections you will see, and what each means:
| Detection | What triggers it | Confidence signal |
|---|---|---|
| Leaked credentials | The app’s secret/cert was found in a public leak (repos, paste sites, dark web) | High - act immediately |
| Anomalous service principal activity | Behaviour deviates from the SP’s learned baseline (new resources, unusual Graph calls, spike in volume) | Medium - investigate |
| Suspicious sign-ins (workload identities) | Sign-in properties inconsistent with the principal’s normal pattern | Medium |
| Admin-confirmed SP compromised | An analyst manually flagged it | High (drives automation) |
| Malicious application / Suspicious application | Microsoft threat intelligence flags the app itself | High |
Risk is expressed as a level and a state; the combination drives what you do:
| Risk level | Risk state | Meaning | Action |
|---|---|---|---|
high |
atRisk |
Strong compromise signal, unremediated | Block via risk-CA; investigate now |
medium |
atRisk |
Suspicious, unremediated | Block or investigate per policy |
low |
atRisk |
Weak signal | Monitor; tune to reduce noise |
| any | confirmedCompromised |
Admin confirmed | Contain immediately (playbook) |
| any | dismissed |
Admin ruled benign | Excluded from risk policies |
| any | remediated |
Credentials rotated / resolved | Back to normal |
Query risky service principals
With IdentityRiskyServicePrincipal.Read.All:
Get-MgRiskyServicePrincipal -All |
Where-Object { $_.RiskLevel -in @('high','medium') -and $_.RiskState -eq 'atRisk' } |
Select-Object DisplayName, AppId, RiskLevel, RiskState, RiskLastUpdatedDateTime |
Sort-Object RiskLastUpdatedDateTime -Descending | Format-Table -AutoSize
Close the loop with a risk-based block
The automated containment that runs at 3 a.m. without you - any service principal at elevated risk is blocked until remediated:
$risk = @{
displayName = "WL - Block risky service principals"
state = "enabled"
conditions = @{
clientApplications = @{
includeServicePrincipals = @("ServicePrincipalsInMyTenant")
excludeServicePrincipals = @("<break-glass-automation-sp-object-id>")
}
applications = @{ includeApplications = @("All") }
servicePrincipalRiskLevels = @("high","medium")
}
grantControls = @{ operator = "OR"; builtInControls = @("block") }
}
New-MgIdentityConditionalAccessPolicy -BodyParameter $risk
Remediation and dismissal are deliberate acts with different downstream effects - know which you mean:
| Analyst action | Graph / portal | Effect on risk state | When to use |
|---|---|---|---|
| Confirm compromised | Confirm-MgRiskyServicePrincipalCompromised |
→ confirmedCompromised |
You have evidence of compromise |
| Dismiss | Invoke-MgDismissRiskyServicePrincipal |
→ dismissed |
Confirmed benign / false positive |
| Remediate (rotate) | Rotate credential, resolve underlying cause | → remediated after re-eval |
After you have fixed the root cause |
| Leave at risk | (no action) | Stays atRisk (blocked by policy) |
Pending investigation |
Going secretless: the credential ladder
The most durable fix is to delete the secret. Rank every workload on the ladder and climb as high as its hosting allows. This is the decision table:
| Rung | Credential model | Use when | Secret to leak? | Rotation burden |
|---|---|---|---|---|
| 1 (best) | Managed identity | Workload runs in Azure (VM, App Service, Functions, AKS, Container Apps, Automation) | None | Zero - platform-managed |
| 2 | Federated credential (FIC) | Workload runs outside Azure (GitHub, GitLab, other clouds, K8s) | None (OIDC exchange) | Zero - short-lived tokens |
| 3 | Certificate | Must store a credential; hosting supports certs | Private key (harder to steal) | Periodic; overlap on rotate |
| 4 (worst) | Client secret | Nothing above is possible (legacy/partner) | Bare bearer string | Frequent; cap the lifetime |
Rung 1 - managed identities for Azure-hosted workloads
Azure manages the credential lifecycle entirely; there is no secret you can leak. Prefer user-assigned so the identity outlives a single resource and is reusable across a workload’s fleet:
az identity create -g rg-platform-prod -n id-app-prod
PRINCIPAL_ID=$(az identity show -g rg-platform-prod -n id-app-prod --query principalId -o tsv)
# Grant least-privilege data-plane access, not Owner - e.g. read one Key Vault
az role assignment create --assignee "$PRINCIPAL_ID" \
--role "Key Vault Secrets User" \
--scope "/subscriptions/$SUB_ID/resourceGroups/rg-platform-prod/providers/Microsoft.KeyVault/vaults/kv-app-prod"
System-assigned versus user-assigned is a real design choice, not a coin flip:
| Property | System-assigned MI | User-assigned MI |
|---|---|---|
| Lifecycle | Tied to one resource; deleted with it | Independent object; reusable |
| Reuse across resources | No | Yes (share across a fleet) |
| Pre-provision RBAC before deploy | Hard (identity exists only after resource) | Easy (create identity first) |
| Blast radius | One resource’s identity | Shared - scope roles carefully |
| Best for | Single dedicated resource | Fleets, blue/green, pre-provisioned RBAC |
Rung 2 - federated identity credentials for off-Azure workloads
For workloads outside Azure, a federated identity credential replaces the stored secret with a short-lived OIDC token whose issuer/subject/audience you pre-register. Entra exchanges the token for an access token only on an exact claim match. For GitHub Actions on the main branch:
az ad app federated-credential create --id "$APP_ID" --parameters '{
"name": "gha-main",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:kloudvin/platform:ref:refs/heads/main",
"audiences": ["api://AzureADTokenExchange"]
}'
The subject is an exact string match - no wildcards. Register one credential per branch/environment you actually deploy from. That tightness is the point: a leaked workflow file cannot move the trust to another branch. Note the hard limit of 20 federated credentials per app registration - if you need more granularity than that, split across apps or use a flexible subject-matching (claims-matching) expression where supported.
Common FIC subjects, so you get the string exactly right:
| Workload | issuer |
subject shape |
|---|---|---|
| GitHub Actions (branch) | https://token.actions.githubusercontent.com |
repo:<org>/<repo>:ref:refs/heads/<branch> |
| GitHub Actions (environment) | https://token.actions.githubusercontent.com |
repo:<org>/<repo>:environment:<env> |
| GitHub Actions (pull request) | https://token.actions.githubusercontent.com |
repo:<org>/<repo>:pull_request |
| GitLab CI | https://gitlab.com |
project_path:<group>/<project>:ref_type:branch:ref:<branch> |
| Kubernetes (AKS/other) | Cluster OIDC issuer URL | system:serviceaccount:<namespace>:<sa-name> |
Delete the secret after migrating
Once federation or a managed identity is in place, remove every password credential so there is nothing left to steal:
$app = Get-MgApplication -Filter "appId eq '$AppId'"
$app.PasswordCredentials | ForEach-Object {
Remove-MgApplicationPassword -ApplicationId $app.Id -KeyId $_.KeyId
}
# Verify none remain
(Get-MgApplication -Filter "appId eq '$AppId'").PasswordCredentials.Count # expect 0
The full implementation of federation across GitHub Actions and AKS - the pipeline side, OIDC configuration, and the trust plumbing - is covered in depth in Secretless CI/CD: Workload Identity Federation for GitHub Actions and AKS. Here we focus on the directory-side outcome: no credential on the app, and a tightly-scoped trust to a single external identity.
Rungs 3 and 4 - certificates and secrets, if you must
When a workload genuinely cannot climb higher, prefer a certificate over a secret, and if you are stuck on a secret, cap its lifetime hard (next section). The security difference is real:
| Property | Certificate | Client secret |
|---|---|---|
| Credential material | Asymmetric key pair; private key can be in an HSM/Key Vault | Symmetric bearer string |
| Exfiltration | Must steal the private key (harder) | Copy the string (trivial) |
| Storage | Key Vault, cert store, HSM | Wherever the app config is |
| Rotation | Overlap: add new cert, then remove old | Same, but strings sprawl |
| Default lifetime | You choose (keep short) | Up to 24 months by default |
| Verdict | Acceptable stopgap | Last resort - cap and monitor |
Governing app permissions and consent
Two governance moves cut the permission blast radius and stop the estate from re-growing.
Least privilege: scope high-impact permissions
If an app only needs to act on a subset of resources, use the scoped alternative instead of the tenant-wide permission. The canonical example is mail: an app that sends as one mailbox should be constrained by an Exchange Application Access Policy, not granted tenant-wide Mail.Send:
New-ApplicationAccessPolicy -AppId $AppId `
-PolicyScopeGroupId "svc-mailers@contoso.com" `
-AccessRight RestrictAccess `
-Description "Limit app to mailboxes in svc-mailers group"
The scoping mechanisms by workload, so you reach for the right one:
| Broad permission | Scoped alternative | Mechanism | Applies to |
|---|---|---|---|
Mail.* (application) |
Restrict to a mail-enabled group | Exchange Application Access Policy / RBAC for Applications | Exchange Online |
Files.ReadWrite.All |
Sites.Selected |
Per-site admin consent grant | SharePoint/OneDrive |
Chat.Read.All |
Resource-specific consent (RSC) | Per-team/chat consent | Teams |
User.Read.All |
Delegated User.Read |
Sign-in as user instead of app-only | Graph users |
Group.ReadWrite.All |
GroupMember.Read.All |
Narrower app role | Graph groups |
Turn off ad-hoc user consent
Stop ordinary users from consenting apps into your tenant; route requests through an admin consent workflow with named reviewers. First restrict user consent to verified publishers and low-impact permissions only (or disable it entirely):
Update-MgPolicyAuthorizationPolicy -BodyParameter @{
defaultUserRolePermissions = @{
permissionGrantPoliciesAssigned = @("ManagePermissionGrantsForSelf.microsoft-user-default-low")
}
}
The consent policy spectrum, from open to locked:
| Setting | permissionGrantPoliciesAssigned value |
Effect | Recommended for |
|---|---|---|---|
| Disable user consent | @() (empty) |
Only admins can consent to any app | High-security tenants |
| Verified + low impact | ManagePermissionGrantsForSelf.microsoft-user-default-low |
Users consent only to low-risk scopes from verified publishers | Most orgs (with admin workflow) |
| Recommended baseline | ManagePermissionGrantsForSelf.microsoft-user-default-recommended |
Broader than “low” but still bounded | Lower-risk tenants |
| Allow all (default legacy) | (broad custom policy) | Users consent to most apps | Avoid |
Then enable the admin consent request workflow so a user who needs a new app generates a request that reviewers approve - a governed front door instead of silent grants. This is deep enough to warrant its own treatment; see Governing OAuth Consent and Application Permissions in Entra ID: Stopping Illicit Consent and Hardening App Trust.
Cap credential lifetime with an app management policy
Even with secretless as the goal, stop anyone creating a two-year secret again. An app management policy enforces credential restrictions tenant-wide:
New-MgPolicyAppManagementPolicy -BodyParameter @{
displayName = "No long-lived secrets"
isEnabled = $true
restrictions = @{
passwordCredentials = @(@{
restrictionType = "passwordLifetime"
maxLifetime = "P90D" # 90-day ceiling on client secrets
state = "enabled"
})
}
}
Better yet, set the tenant default app management policy so it applies to all apps without per-app assignment, and consider blocking new password credentials entirely (forcing certs/FIC):
| Restriction type | What it caps/blocks | Example value | Effect |
|---|---|---|---|
passwordLifetime |
Max client-secret lifetime | P90D |
Rejects secrets longer than 90 days |
passwordAddition |
Adding any new password credential | state: enabled |
Blocks new secrets outright - forces cert/FIC |
customPasswordAddition |
New passwords not matching rules | rule set | Fine-grained secret control |
keyLifetime |
Max certificate lifetime | P180D |
Caps cert validity |
asymmetricKeyLifetime |
Max asymmetric key lifetime | P180D |
Caps key validity |
trustedCertificateAuthority |
Which CAs may issue app certs | CA set | Only your PKI’s certs accepted |
Enforcement note: app management policy restrictions apply to credentials added after the policy is enabled (there is an
restrictForAppsCreatedAfterDateTimecontrol on the tenant default policy). They do not retroactively delete existing long-lived secrets - your inventory-and-remove hygiene job handles those.
Monitoring service principal sign-ins and audit
Detection is where governance becomes operational. Route the right logs to Log Analytics/Sentinel via a diagnostic setting on Entra, then build detections on the tables that carry workload-identity signal.
The log tables that matter
| Log Analytics table | What it captures | Key use |
|---|---|---|
AADServicePrincipalSignInLogs |
App-only (client-credentials) sign-ins | Location anomalies, failed CA blocks |
AADManagedIdentitySignInLogs |
Managed-identity token acquisitions | MI behaviour, unexpected resource access |
AuditLogs |
Directory changes (credential add, consent, role) | The persistence-move detections |
MicrosoftGraphActivityLogs |
Per-token Graph API calls | Blast-radius reconstruction; which resources touched |
AADRiskyServicePrincipals |
Current risk state of SPs | Risk dashboards, hunting |
AADServicePrincipalRiskEvents |
Individual SP risk detections | Detection correlation |
Detection 1 - the classic persistence move (credential added)
Alerting on a new credential added to an app catches the single most common persistence action. Wire this as a Sentinel analytics rule:
AuditLogs
| where OperationName in ("Add service principal credentials",
"Update application - Certificates and secrets management")
| extend Target = tostring(TargetResources[0].displayName)
| extend Actor = tostring(parse_json(tostring(InitiatedBy.user)).userPrincipalName)
| extend ActorApp = tostring(parse_json(tostring(InitiatedBy.app)).displayName)
| project TimeGenerated, OperationName, Target, Actor, ActorApp, Result
| sort by TimeGenerated desc
Detection 2 - a service principal suddenly authenticating from many IPs
AADServicePrincipalSignInLogs
| where TimeGenerated > ago(7d)
| where ResultType == 0 // successful sign-ins only
| summarize SignIns = count(), IPs = make_set(IPAddress, 50) by ServicePrincipalName, AppId
| extend DistinctIPs = array_length(IPs)
| where DistinctIPs > 5 // apps suddenly auth'ing from many IPs
| sort by DistinctIPs desc
Detection 3 - a new app permission granted
AuditLogs
| where OperationName in ("Add app role assignment to service principal",
"Add delegated permission grant",
"Consent to application")
| extend App = tostring(TargetResources[0].displayName)
| extend Actor = tostring(parse_json(tostring(InitiatedBy.user)).userPrincipalName)
| project TimeGenerated, OperationName, App, Actor, Result
| sort by TimeGenerated desc
The high-signal audit operations to build rules on, and why each matters:
| Audit operation | The move it catches | Severity |
|---|---|---|
Add service principal credentials |
Attacker persistence (2nd secret) | High |
Update application - Certificates and secrets management |
Credential change / persistence | High |
Add app role assignment to service principal |
Privilege escalation (new app permission) | High |
Consent to application |
New app admitted to tenant | Medium-High |
Add owner to service principal / ...to application |
Attacker gains control of the app | Medium-High |
Add member to role (SP as target) |
SP added to a directory role | High |
Add federated identity credential |
Rogue FIC persistence | High |
For deeper hunting patterns across these tables - MITRE mapping, UEBA baselines, and reusable notebooks - see KQL Threat Hunting Playbooks: MITRE ATT&CK Mapping, UEBA, and Hunting Notebooks.
Break-glass, emergency handling, and the compromise playbook
When a principal is compromised, time-to-contain is everything - and the app-only-token reality shapes the whole runbook. Here is the playbook, with the critical nuance that disabling does not instantly cut existing tokens:
| # | Step | Command / action | Cuts future access | Cuts existing tokens |
|---|---|---|---|---|
| 1 | Confirm compromised in Identity Protection | Confirm-MgRiskyServicePrincipalCompromised |
Drives risk-CA block | No |
| 2 | Hard-disable the SP | Update-MgServicePrincipal -AccountEnabled:$false |
Yes (no new tokens) | No - existing app tokens live to expiry |
| 3 | Remove all credentials | Remove every PasswordCredentials/KeyCredentials |
Yes | No |
| 4 | Remove rogue FICs | Remove-MgApplicationFederatedIdentityCredential |
Yes | No |
| 5 | Break the resource trust | Rotate downstream secrets/keys the app used; revoke Azure RBAC | Yes | Yes - the only true instant cutoff |
| 6 | Hunt for persistence | Diff creds, owners, app-roles, FICs vs known-good | Detection | - |
| 7 | Assess blast radius | Pull MicrosoftGraphActivityLogs for the AppId |
Detection | - |
| 8 | Rebuild clean | Re-create secretless, least-privilege, behind CA | Recovery | - |
Step 2 is where teams get burned: they disable the SP, declare victory, and the attacker’s already-issued token keeps reading mail for another hour. The only ways to defeat a token already in flight are to wait out its (short) lifetime, rely on CAE where the resource supports near-real-time revocation, or - the reliable one - break the trust the app relied on downstream (rotate the database password it used, revoke its Azure role assignments so the token authorizes nothing). Plan for all three.
Disable and purge, concretely
# 1-2. Confirm + disable
Confirm-MgRiskyServicePrincipalCompromised -ServicePrincipalIds @($spId)
Update-MgServicePrincipal -ServicePrincipalId $spId -AccountEnabled:$false
# 3. Remove all credentials on the app registration
$app = Get-MgApplication -Filter "appId eq '$appId'"
$app.PasswordCredentials | ForEach-Object { Remove-MgApplicationPassword -ApplicationId $app.Id -KeyId $_.KeyId }
$app.KeyCredentials | ForEach-Object { Remove-MgApplicationKey -ApplicationId $app.Id -KeyId $_.KeyId }
# 4. Remove any federated credentials (attackers add these for durable persistence)
Get-MgApplicationFederatedIdentityCredential -ApplicationId $app.Id |
ForEach-Object { Remove-MgApplicationFederatedIdentityCredential -ApplicationId $app.Id -FederatedIdentityCredentialId $_.Id }
Hunt for the attacker’s persistence
Attackers rarely add just one thing. The diff you run against your last known-good export:
| Artifact to diff | Command | What a compromise looks like |
|---|---|---|
| Password credentials | (Get-MgApplication ...).PasswordCredentials |
An extra secret you did not create |
| Key credentials (certs) | ...KeyCredentials |
An unexpected certificate |
| Federated credentials | Get-MgApplicationFederatedIdentityCredential |
A FIC to an attacker-controlled issuer/subject |
| Owners | Get-MgApplicationOwner / ...ServicePrincipalOwner |
A new, unexpected owner |
| App-role assignments | Get-MgServicePrincipalAppRoleAssignment |
Newly granted high-impact permissions |
| Directory-role membership | Get-MgRoleManagementDirectoryRoleAssignment |
The SP added to a privileged role |
Break-glass automation principal
Keep one tightly-held emergency automation identity excluded from the workload CA policies so a bad IP list or a mass risk event cannot lock you out of your own tooling. The design constraints:
| Break-glass control | Requirement |
|---|---|
| Credential | Certificate (not a secret); stored in an HSM/privileged vault |
| CA exclusion | Excluded from location and risk workload policies |
| Monitoring | Alert on every sign-in and any change to the object |
| Permissions | Only what remediation needs (disable SPs, remove creds) - not Tier 0 by default |
| Rotation | Certificate rotated on a fixed schedule; access split among ≥2 admins |
| Testing | Exercised in every tabletop so it works when you need it |
The forensics and runbook discipline behind steps 6-8 is a discipline of its own; pair this with Engineering Incident Response: Runbooks, Tabletop Exercises, and Cloud Forensics.
Architecture at a glance
Picture the workload-identity control plane as three concentric rings around every service principal, with a request flowing left to right through them.
The outer ring is admission and issuance. A workload - a CI runner, a daemon, an Azure-hosted service - needs a token. If it is Azure-hosted, it asks the managed identity endpoint and Azure mints the credential with nothing stored. If it is off-Azure, it presents a short-lived OIDC token and Entra performs a federated credential exchange, matching issuer/subject/audience exactly. Only if neither applies does the workload present a stored certificate or, worst case, a client secret. This ring is the secretless ladder in motion: the higher the rung the workload sits on, the less there is to steal.
The middle ring is the policy gate. Every service-principal sign-in that reaches the token endpoint passes through workload-identity Conditional Access. The location condition asks “is this coming from a named egress I trust?” and blocks if not. The risk condition asks “has Identity Protection flagged this principal as risky?” and blocks if so. Managed identities and the one break-glass automation principal are the deliberate exclusions. This ring is where a stolen secret dies: the credential is valid, but the sign-in originates from the wrong place or the principal is already flagged, and the token is never issued.
The inner ring is authorization and least privilege. A token that clears the gate carries only the application permissions the app was granted - and governance has already ensured those are least-privilege: Sites.Selected not Files.ReadWrite.All, an Exchange Application Access Policy not tenant-wide Mail.*, no Tier-0 escalation primitives unless justified. Consent policy kept new apps from silently accumulating here; the app management policy kept anyone from minting a long-lived secret to feed the outer ring.
Wrapping all three is the observability plane. Every sign-in lands in AADServicePrincipalSignInLogs, every credential-add and permission-grant in AuditLogs, every Graph call in MicrosoftGraphActivityLogs, and every risk verdict in the risk tables. Sentinel rules watch for the persistence moves - a new credential, a new app role, a rogue FIC - and Identity Protection feeds the risk condition in the middle ring, closing the loop. Read the picture left to right and you see the whole story: mint with no secret, gate on context and risk, authorize with least privilege, and watch everything - so a compromise is blocked at the gate, and if it slips through, it is loud.
Real-world scenario
A global logistics platform team - call them Meridian Freight - ran ~340 service principals across three regions: SaaS connectors, per-team CI deployers, on-prem schedulers, and a long tail of apps consented years earlier. During a tabletop exercise their red team demonstrated the gap precisely. They scraped a CI deployer’s client secret from an archived pipeline log, planted it on a VPS in another country, and authenticated to Microsoft Graph successfully. The app held Mail.Read (application) and a secret valid for another 14 months. Nothing alerted - no user, no MFA, no device, nothing the existing controls watched.
The constraint was that Meridian could not go fully secretless overnight. A dozen partner-operated apps and two on-prem schedulers genuinely needed stored credentials for another two quarters. So they sequenced the fix, leading with the control that pays off fastest.
First, the location lock. They licensed Workload ID Premium on the ~90 first-party service principals that held any real permission, defined a named location containing only their three NAT egress CIDRs plus their GitHub runner ranges, and rolled out a block-outside-egress policy in report-only for two weeks. That report-only window immediately earned its keep: it surfaced four shadow automations authenticating from a developer’s home IP, which became the first cleanup. When they enforced, the exact VPS replay from the tabletop was blocked at the token endpoint - the smoking-gun row appeared in AADServicePrincipalSignInLogs with a Conditional Access failureReason.
In parallel, the secretless migration. Everything Azure-resident moved to user-assigned managed identities. The GitHub deployers moved to federated credentials, one per repo-and-branch. As each workload was cut over, its secrets were deleted from the app registration and verified empty.
Then the governance rails. An app management policy capped secret lifetime at 90 days tenant-wide so the long-lived-secret problem could not regrow, user consent was restricted to verified publishers with an admin approval workflow, and the Mail.Read app that started it all was re-scoped: it never needed all mailboxes, so an Exchange Application Access Policy constrained it to a 12-mailbox distribution group.
Finally, detection. Identity Protection risk detections for workload identities - previously unmonitored - were wired into Sentinel with a playbook that auto-disabled the flagged principal and paged on-call. The “leaked credentials” detection, in particular, now fired within minutes of a secret appearing in a public repo.
Six months later the credential count was down from ~340 secrets to 11 - all partner apps on a tracked retirement plan. In the post-mortem the team was explicit: the highest-leverage move was not the secretless migration, valuable as it was. It was the location lock, because it neutralised stolen secrets on day one while the slower migration caught up. The lesson they underlined: when they first ran the tabletop replay after disabling the compromised SP, the attacker’s token kept working for another 47 minutes - which is why “break the downstream trust” is now step 5 of their runbook, not an afterthought.
Advantages and disadvantages
Governing workload identities properly is not free - it costs licensing, engineering time, and a permanent operational discipline. The trade-off, honestly:
| Advantages | Disadvantages |
|---|---|
| Neutralises stolen secrets immediately (location lock) | Workload ID Premium is a per-SP add-on cost |
| Secretless removes the stealable thing entirely | Migration is real engineering effort, workload by workload |
| Least-privilege permissions shrink blast radius | Scoping (Sites.Selected, AAP) adds admin overhead |
| Risk detections give automated 3 a.m. containment | Requires Identity Protection licensing + tuning |
| Inventory + audit answers auditor/IR questions instantly | Ongoing hygiene job must actually be run |
| App-only-token reality is now understood, not assumed | Cannot instantly revoke tokens - containment is multi-step |
| Consent + app-mgmt policy stop the estate re-growing | Tighter consent can create user-friction requests to triage |
When each matters: the location lock matters most in tenants with many stored secrets you cannot migrate quickly - it buys time. Secretless matters most where workloads are modern and Azure/CI-hosted - climb the ladder and the whole class of leaked-secret incidents disappears. Least privilege and consent governance matter most in SaaS-heavy, multi-team tenants where app sprawl is the dominant risk. Risk detection pays for itself fastest in high-value tenants (finance, healthcare) where dwell time is the metric that ends careers.
Hands-on lab
This lab builds the core controls end to end in a test tenant. It uses Microsoft Graph PowerShell and az. Some steps require Workload ID Premium on the test SP and Identity Protection; where a step needs a paid SKU it is flagged, and the free steps still stand alone.
Step 1 - create a test app and give it a (deliberately bad) secret
Connect-MgGraph -Scopes "Application.ReadWrite.All","Directory.ReadWrite.All","Policy.ReadWrite.ConditionalAccess","Policy.ReadWrite.ApplicationConfiguration"
$app = New-MgApplication -DisplayName "lab-workload-id"
$sp = New-MgServicePrincipal -AppId $app.AppId
$pwd = Add-MgApplicationPassword -ApplicationId $app.Id -PasswordCredential @{
displayName = "lab-secret"; endDateTime = (Get-Date).AddYears(2) # deliberately long
}
"AppId: $($app.AppId) Secret set, expires: $($pwd.EndDateTime)"
Expected: an app + SP, and a two-year secret you will now govern away.
Step 2 - inventory it (confirm the bad secret shows up)
Get-MgApplication -Filter "appId eq '$($app.AppId)'" -Property DisplayName,PasswordCredentials |
Select-Object DisplayName, @{n='Secrets';e={$_.PasswordCredentials.Count}},
@{n='Expiry';e={$_.PasswordCredentials.EndDateTime}}
Validation: Secrets = 1, Expiry ~2 years out. This is exactly what your real inventory job flags as P3-migrate.
Step 3 - enforce an app management policy capping secret lifetime
$pol = New-MgPolicyAppManagementPolicy -BodyParameter @{
displayName = "lab-no-long-secrets"; isEnabled = $true
restrictions = @{ passwordCredentials = @(@{
restrictionType = "passwordLifetime"; maxLifetime = "P90D"; state = "enabled" }) }
}
# Assign it to the lab app
New-MgPolicyAppManagementPolicyAppliesToByRef -AppManagementPolicyId $pol.Id -BodyParameter @{
"@odata.id" = "https://graph.microsoft.com/v1.0/applications/$($app.Id)"
}
# Now try to add a 2-year secret - it should be rejected
try {
Add-MgApplicationPassword -ApplicationId $app.Id -PasswordCredential @{
displayName = "too-long"; endDateTime = (Get-Date).AddYears(2) }
} catch { "Rejected as expected: $($_.Exception.Message)" }
Expected: the second Add fails - the policy refuses a secret longer than 90 days.
Step 4 - create a named location and a report-only workload CA policy
$loc = New-MgIdentityConditionalAccessNamedLocation -BodyParameter @{
"@odata.type" = "#microsoft.graph.ipNamedLocation"
displayName = "lab-egress"; isTrusted = $true
ipRanges = @(@{ "@odata.type" = "#microsoft.graph.iPv4CidrRange"; cidrAddress = "203.0.113.0/24" })
}
$ca = New-MgIdentityConditionalAccessPolicy -BodyParameter @{
displayName = "lab-WL-block-outside-egress"
state = "enabledForReportingButNotEnforced"
conditions = @{
clientApplications = @{ includeServicePrincipals = @($sp.Id) }
applications = @{ includeApplications = @("All") }
locations = @{ includeLocations = @("All"); excludeLocations = @($loc.Id) }
}
grantControls = @{ operator = "OR"; builtInControls = @("block") }
}
"Policy $($ca.DisplayName) is $($ca.State)"
Validation: the policy exists in report-only, targeting only your lab SP by object ID (safe - it will not touch other apps).
Step 5 - go secretless: give the app a federated credential, delete the secret
APP_ID="<app.AppId from step 1>"
az ad app federated-credential create --id "$APP_ID" --parameters '{
"name": "lab-gha-main",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:kloudvin/lab:ref:refs/heads/main",
"audiences": ["api://AzureADTokenExchange"]
}'
# Remove every secret now that federation exists
$a = Get-MgApplication -Filter "appId eq '$($app.AppId)'"
$a.PasswordCredentials | ForEach-Object { Remove-MgApplicationPassword -ApplicationId $a.Id -KeyId $_.KeyId }
(Get-MgApplication -Filter "appId eq '$($app.AppId)'").PasswordCredentials.Count # expect 0
Expected: 0 secrets remain; the app now trusts a single exact GitHub subject. There is nothing left to leak.
Step 6 - teardown
Remove-MgApplicationFederatedIdentityCredential -ApplicationId $a.Id `
-FederatedIdentityCredentialId (Get-MgApplicationFederatedIdentityCredential -ApplicationId $a.Id).Id
Remove-MgIdentityConditionalAccessPolicy -ConditionalAccessPolicyId $ca.Id
Remove-MgIdentityConditionalAccessNamedLocation -NamedLocationId $loc.Id
Remove-MgPolicyAppManagementPolicy -AppManagementPolicyId $pol.Id
Remove-MgServicePrincipal -ServicePrincipalId $sp.Id
Remove-MgApplication -ApplicationId $a.Id
"Lab torn down."
Each stage has its own inline validation above - Secrets = 1 after step 2, a rejected long secret in step 3, a lab-SP-only policy in step 4, and PasswordCredentials.Count = 0 after step 5 prove the controls actually took effect before you tear down.
Common mistakes & troubleshooting
The failure modes that burn the most time, as a symptom → cause → confirm → fix playbook:
| # | Symptom | Root cause | Confirm (command / portal path) | Fix |
|---|---|---|---|---|
| 1 | Workload CA policy created but SP sign-ins never blocked | Policy targets multi-tenant / third-party SPs (unsupported) or SP not licensed | Sign-in log shows no CA applied; check includeServicePrincipals |
Target single-tenant SPs; license them for Workload ID Premium |
| 2 | Disabled compromised SP, but it kept reading data | App-only token already issued lives to expiry | MicrosoftGraphActivityLogs shows calls after disable time |
Break downstream trust (rotate resource creds, revoke RBAC); rely on CAE where supported |
| 3 | Location lock broke a managed identity | Named location omits the Azure service’s egress ranges | MI sign-in in AADManagedIdentitySignInLogs blocked by CA |
Scope MI policies narrowly; include the platform’s outbound ranges |
| 4 | “Add secret” still allowed despite app-mgmt policy | Policy not assigned to the app, or created before policy | Get-MgPolicyAppManagementPolicyAppliesTo; check restrictForAppsCreatedAfterDateTime |
Assign policy (or set tenant default); note it is not retroactive |
| 5 | Deleted the secret but the app still authenticates | A second credential (secret/cert/FIC) exists | PasswordCredentials, KeyCredentials, Get-MgApplicationFederatedIdentityCredential |
Remove all credentials, including rogue FICs |
| 6 | FIC configured but token exchange fails | subject/issuer/audience mismatch (exact string) |
Runtime error AADSTS700213/700211; compare claim vs FIC |
Match the exact subject string; one FIC per branch/env |
| 7 | Risk-based workload CA never triggers | Identity Protection not licensed, or no risk detections yet | Get-MgRiskyServicePrincipal empty; check licensing |
License Workload ID Premium; verify Identity Protection is on |
| 8 | Users still consenting random apps into the tenant | Consent policy left at permissive default | Get-MgPolicyAuthorizationPolicy shows broad permissionGrantPoliciesAssigned |
Restrict to ...default-low or @(); enable admin consent workflow |
| 9 | App broke at 2 a.m. - “invalid client secret” | Secret expired; no rotation/alerting | Sign-in log AADSTS7000215; inventory shows past EndDateTime |
Rotate (or migrate to FIC/MI); wire expiry alerting |
| 10 | Cannot see SP sign-ins at all | No diagnostic setting routing SP sign-in logs, or no Workload ID Premium | Log Analytics has no AADServicePrincipalSignInLogs rows |
Add Entra diagnostic setting; confirm licensing for SP sign-in category |
| 11 | Locked yourself out running remediation | Break-glass automation SP not excluded from the block policy | Your tooling SP hits the CA failureReason |
Exclude one hardened break-glass SP; test it in tabletops |
| 12 | Tier-0 permission review shows an app you cannot identify | Orphan app, no owner, consented years ago | Get-MgServicePrincipalOwner returns none; no recent sign-in |
Disable, investigate, delete after grace; alert on future Tier-0 grants |
| 13 | Report-only workload CA shows unexpected “would block” hits | Shadow automation running from an un-enumerated IP | Report-only results in sign-in logs, filter by SP | Add the legitimate IP or retire the shadow automation before enforcing |
| 14 | Certificate rotation caused an outage | Old cert removed before new cert deployed (gap, not overlap) | Sign-in failures right after removal | Always overlap: add new key, deploy, then remove old |
Best practices
- Lead with the location lock. It is the fastest-paying control - it neutralises stolen secrets on day one. Roll it out in report-only, read the results, then enforce. License the service principals that hold real permission.
- Exclude exactly one hardened break-glass automation principal from every workload CA policy, monitor its every sign-in, and exercise it in tabletops.
- Climb the secretless ladder relentlessly: managed identity for Azure-hosted, federated credentials for off-Azure, certificate if you must store a credential, client secret only as a last resort - and delete the secret the moment you migrate.
- Never assume disabling an SP cuts existing access. App-only tokens live to expiry. Contain by disabling and breaking the downstream trust (rotate resource secrets, revoke RBAC).
- Treat Tier-0 Graph permissions as tenant-takeover primitives.
RoleManagement.ReadWrite.Directory,AppRoleAssignment.ReadWrite.All,Application.ReadWrite.All- justify every holder or remove it. - Prefer scoped permissions over tenant-wide:
Sites.SelectedoverFiles.ReadWrite.All, an Exchange Application Access Policy overMail.*, resource-specific consent overChat.Read.All. - Cap secret lifetime in policy with an app management policy (e.g. 90 days), and consider blocking new secrets entirely to force certs/FIC. Set the tenant default so it applies everywhere.
- Turn off ad-hoc user consent and route new apps through an admin consent workflow with named reviewers.
- Run inventory and hygiene as a scheduled job: owners, last sign-in, credential expiry, Tier-0 holders. Disable-then-delete orphans on a grace window - never hard-delete first.
- Wire the persistence-move detections (credential added, app role granted, rogue FIC, SP risk raised) into Sentinel with an auto-containment playbook.
- Certificate rotation overlaps, never gaps - add the new key, deploy, then remove the old one.
- Manage app credentials and permissions as code, reviewed in PRs, so a long-lived secret or a Tier-0 grant cannot be added silently.
Security notes
- Least privilege is the whole game for app permissions. An app-only token carries tenant-wide reach - grant the narrowest app role that works, and scope it further (Sites.Selected, Application Access Policy, RSC) wherever the workload supports it.
- Secretless beats every secret-management scheme. A managed identity or a federated credential has no stealable material; that is categorically stronger than even a well-vaulted secret. Where a credential must exist, a certificate in an HSM/Key Vault beats a bare secret.
- The token-revocation reality changes your IR posture. Because app-only tokens cannot be revoked before expiry, your containment must break the resource trust, not just the identity. Rotate what the app could reach, and shorten token lifetimes where policy allows.
- Protect the observability plane itself. The sign-in, audit, and Graph activity logs are your only witnesses; route them to a Log Analytics workspace with tight RBAC and retention, and alert on any change to the diagnostic settings.
- Harden the break-glass automation principal like a Tier-0 asset: certificate credential, HSM storage, split control among admins, alert on every use.
- Verify publishers for third-party apps and prefer apps that support
Sites.Selected/RSC/AAP scoping. An unverified third-party app holdingMail.Read(application) is a supply-chain risk, not a convenience. - Tie this to your broader Zero Trust program. Workload identities are one pillar; see Zero Trust Architecture Blueprint: Identity, Network, and Data Pillars for how they fit alongside human identity, network, and data controls, and Privileged Identity Management and PAM Architecture: Just-in-Time Access at Scale for the human privileged-access counterpart.
Cost & sizing
The bill has two real drivers - the Workload ID Premium add-on and your Log Analytics/Sentinel ingestion - and both are modest against the cost of a single mailbox-exfiltration incident.
- Workload Identities Premium is a per-workload-identity, per-month add-on (list price around US$3 per workload identity per month, roughly ₹250/identity/month). You license only the service principals you want to protect with workload CA and risk detections - typically the tens of first-party apps that hold real permission, not the hundreds of trivial ones. Ninety governed principals is on the order of US$270/month (~₹22,000/month), not the whole estate.
- Identity Protection for workloads rides the same Workload ID Premium SKU - there is no separate charge for the SP risk detections once the identity is licensed.
- Managed identities and federated credentials are free - they are platform features. The secretless migration therefore reduces long-run cost (no vault storage for those secrets, no rotation toil) while improving security.
- Log Analytics / Sentinel ingestion is billed per GB. SP sign-in and audit logs are low-volume versus user sign-ins;
MicrosoftGraphActivityLogscan be higher-volume in busy tenants - sample or filter if needed. Budget a few thousand INR/month for a mid-size tenant’s identity logs.
A rough monthly picture and what each line buys:
| Cost line | What you pay for | Rough figure | What it buys |
|---|---|---|---|
| Workload ID Premium (per SP) | Location + risk CA, SP risk detections | ~₹250/identity/month | Stolen-secret block + compromise detection |
| ~90 governed SPs | The first-party apps that matter | ~₹22,000/month | Coverage of the real blast-radius apps |
| Managed identities / FIC | Platform credentials | Free | Removes the stealable secret entirely |
| Identity log ingestion | SP sign-in + audit + risk tables | ~₹2,000-5,000/month | The detections and IR evidence trail |
| Graph activity logs | Per-token API call telemetry | Variable (sample) | Blast-radius reconstruction |
Right-sizing rule: license by permission, not by count. A read-only reporting SP with no dangerous scope may not warrant the add-on; the CI deployer with Application.ReadWrite.All absolutely does. Rank by the Tier-0/high-impact inventory table and license top-down until the marginal risk no longer justifies the ₹250.
Interview & exam questions
1. Why is a leaked service-principal secret more dangerous than a leaked user password? The secret is a bearer credential that authenticates the app from anywhere with no second factor, no device check, and no location distrust by default; application permissions are tenant-wide, so the blast radius is the whole tenant (every mailbox, not one). User passwords are wrapped in MFA and Conditional Access; the raw secret, by default, is protected by nothing but obscurity.
2. What does workload-identity Conditional Access actually support, and what does it not? It supports block as the only grant control, plus location and service-principal risk conditions, for single-tenant service principals. It does not support MFA, device compliance, session controls, or multi-tenant/third-party SPs. The value is “is this sign-in from an expected place and is the principal currently risky.”
3. You disable a compromised service principal. Is its access cut immediately? No. Disabling stops new token issuance, but any app-only access token already issued remains valid until it expires (~60-90 minutes; there is no revokeSignInSessions for SPs). True instant cutoff requires breaking the downstream trust the token relied on - rotate the resource’s secret, revoke the app’s Azure RBAC - or relying on CAE where the resource supports it.
4. Rank the credential options from best to worst and say why. Managed identity (no credential you can leak, Azure-managed) > federated credential (secretless OIDC exchange for off-Azure) > certificate (a credential, but the private key is harder to exfiltrate and can live in an HSM) > client secret (a bare bearer string, trivially copied, often long-lived). Climb the ladder as high as the hosting allows.
5. Which Graph application permissions are “Tier 0” and why? RoleManagement.ReadWrite.Directory, AppRoleAssignment.ReadWrite.All, and Application.ReadWrite.All (and a few peers). They are escalation primitives: an app holding one can grant itself any other permission or assign itself a directory role - effectively tenant takeover. Every holder must be justified or removed.
6. How do you neutralise a stolen secret you cannot migrate away from yet? A workload-identity Conditional Access location lock - restrict the SP to a named location containing only your legitimate egress IPs. The stolen secret is still valid, but a sign-in from the attacker’s infrastructure is blocked at the token endpoint. It buys time while the secretless migration catches up, which is why it is usually the highest-leverage first move.
7. What licensing is required for workload-identity CA and SP risk detections? Microsoft Entra Workload ID Premium, a per-service-principal add-on separate from user-based Entra ID P2. You license the specific principals you want to protect, not the whole tenant.
8. Name three audit operations that signal attacker persistence on a service principal. Add service principal credentials (a second secret), Add app role assignment to service principal (self-granted permission escalation), and Add federated identity credential (a rogue FIC for durable persistence). Alert on all three; they are the classic post-compromise moves.
9. An Exchange app needs to send mail as a handful of mailboxes. What is the least-privilege design? Do not grant tenant-wide Mail.Send (application). Grant it, then constrain it with an Exchange Application Access Policy (or RBAC for Applications) scoped to a mail-enabled group containing only those mailboxes, so the app-only token can touch only that group.
10. How do you stop the workload-identity estate from re-growing after you clean it up? Two policies: an app management policy capping (or blocking) client secrets tenant-wide so no one mints a two-year secret again, and a consent policy that disables ad-hoc user consent and routes new apps through an admin approval workflow. Plus a scheduled hygiene job that disables-then-deletes orphans.
11. What is a federated credential subject, and why does the exact-match matter? It is the subject claim (e.g. repo:org/repo:ref:refs/heads/main) that Entra matches exactly against the presented OIDC token before issuing an access token. Exact-match (no wildcards) means a leaked workflow file cannot move the trust to another branch or repo - the tightness is the security property.
12. Which certification objectives does this map to? Primarily SC-300 (Identity and Access Administrator) - implement and manage app registrations, consent, and Conditional Access - and SC-100 (Cybersecurity Architect) for the Zero-Trust-for-workloads design. Managed identities and RBAC touch AZ-500 (Azure Security Engineer).
The cert mapping for revision:
| Question theme | Primary cert | Objective area |
|---|---|---|
| Workload CA, consent, app registrations | SC-300 | Manage app access & Conditional Access |
| Identity Protection risk (users + workloads) | SC-300 | Implement identity protection |
| Zero-Trust-for-workloads architecture | SC-100 | Design identity security strategy |
| Managed identities, RBAC, Key Vault | AZ-500 | Manage identity & access; secure compute |
| Least-privilege Graph scopes | SC-300 / SC-100 | Application permission governance |
Quick check
- Your workload-identity Conditional Access policy is enabled but a stolen secret is still being used successfully from another country. Name two likely reasons the policy is not blocking it.
- True or false: disabling a compromised service principal immediately cuts off all of its access. Explain.
- An app needs to read files from exactly two SharePoint sites. What permission should it hold instead of
Files.ReadWrite.All? - You delete the only visible client secret on a compromised app but it keeps authenticating. What three credential artifacts do you check next?
- Which single control neutralises a stolen secret you cannot migrate away from this quarter, and how do you roll it out safely?
Answers
- Either the policy targets multi-tenant/third-party service principals (workload CA only covers your single-tenant SPs) or the SP is not licensed for Workload ID Premium - so no policy applies. Confirm in the sign-in log that no CA was applied, and check
includeServicePrincipalsand licensing. - False. Disabling stops new token issuance, but an app-only access token already issued stays valid until it expires (~60-90 minutes; there is no session-revocation action for SPs). To cut existing access you must break the downstream trust (rotate the resource’s credentials, revoke the app’s RBAC) or rely on CAE where supported.
Sites.Selected, with a per-site consent grant scoping the app to exactly those two sites - not the tenant-wideFiles.ReadWrite.All.- A second client secret, a certificate (
KeyCredentials), and a federated identity credential (Get-MgApplicationFederatedIdentityCredential). Attackers add a second credential or a rogue FIC for persistence, so removing one does nothing. - A workload-identity Conditional Access location lock - restrict the SP to a named location of your legitimate egress IPs. Roll it out in report-only first, read the results (catching any shadow automations), then enforce - and exclude your break-glass automation principal.
Glossary
- App registration — the global definition of an application; holds client secrets, certificates, federated credentials, and the requested permissions.
- Service principal — the tenant-local instance of an application; holds granted permissions (consent), role assignments, sign-in activity, and the
accountEnabledflag. - Managed identity — a service principal whose credential Azure creates and rotates automatically; there is no secret to leak. System-assigned (tied to one resource) or user-assigned (reusable).
- Federated identity credential (FIC) — a trust configured on an app to an external OIDC issuer; the workload presents a short-lived token whose
issuer/subject/audienceare matched exactly, and no secret is stored. - Client secret — a symmetric bearer string that authenticates an app; the weakest credential type, trivially copied, often long-lived.
- Application permission (app role) — a Graph permission the app holds as itself, tenant-wide, with no signed-in user to scope it (e.g.
Mail.Readapplication). - Delegated permission — a Graph permission scoped to what the signed-in user can already do.
- Tier-0 permission — a Graph application permission that is an escalation primitive (
RoleManagement.ReadWrite.Directory,AppRoleAssignment.ReadWrite.All,Application.ReadWrite.All); an app holding one can grant itself anything. - Workload-identity Conditional Access — CA policies targeting service principals; support block plus location and service-principal risk conditions for single-tenant SPs. Requires Workload ID Premium.
- Workload Identities Premium — the per-service-principal add-on SKU that unlocks workload CA and SP risk detections.
- Risky service principal — a service principal flagged by Identity Protection with a risk level and state (
atRisk,confirmedCompromised,dismissed,remediated). - App-only token — an access token issued via the client-credentials grant; it cannot be revoked before it expires.
- App management policy — a policy that restricts app credentials tenant-wide (cap secret lifetime, block new secrets, cap cert lifetime).
- Application Access Policy — an Exchange Online policy that scopes an app’s mailbox access to a specific group instead of the whole tenant.
- Admin consent workflow — a governed request/approval flow for admitting new apps, replacing ad-hoc user consent.
- Named location — a set of trusted IP ranges referenced by Conditional Access conditions.
- MicrosoftGraphActivityLogs — the Log Analytics table of per-token Graph API calls, used to reconstruct a compromised principal’s blast radius.
- Continuous Access Evaluation (CAE) — near-real-time token revocation for the subset of resources that support it, shortening the window in which a revoked identity’s token still works.
Next steps
You can now inventory, protect, watch, and de-secret your workload identities, and contain one when it is compromised. Build outward:
- The implementation companion: Secretless CI/CD: Workload Identity Federation for GitHub Actions and AKS — the pipeline-side plumbing for the federated credentials this article governs.
- Related: Managed Identities Deep Dive: User-Assigned Identities, Federated Credentials, and RBAC Patterns for Azure Workloads — go deep on the top rung of the secretless ladder.
- Related: Operationalizing Entra ID Protection: Risk-Based Conditional Access, Detection Tuning, and Risk Investigation — the risk engine and how to tune it, on both the user and workload sides.
- Related: Governing OAuth Consent and Application Permissions in Entra ID: Stopping Illicit Consent and Hardening App Trust — the consent and permission-governance depth behind Section 7.
- Related: Eliminating Secret Sprawl: Pipeline Scanning, Push Protection, and Leaked-Credential Remediation — catch the leaked secret before an attacker does.
- Related: Engineering Incident Response: Runbooks, Tabletop Exercises, and Cloud Forensics — the IR discipline behind the compromise playbook.