Security Azure

Locking Down Workload Identities: Conditional Access, Risk Detection, and Going Secretless

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:

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-000000000000 is 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 restrictForAppsCreatedAfterDateTime control 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

Security notes

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.

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

  1. 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.
  2. True or false: disabling a compromised service principal immediately cuts off all of its access. Explain.
  3. An app needs to read files from exactly two SharePoint sites. What permission should it hold instead of Files.ReadWrite.All?
  4. You delete the only visible client secret on a compromised app but it keeps authenticating. What three credential artifacts do you check next?
  5. Which single control neutralises a stolen secret you cannot migrate away from this quarter, and how do you roll it out safely?

Answers

  1. 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 includeServicePrincipals and licensing.
  2. 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.
  3. Sites.Selected, with a per-site consent grant scoping the app to exactly those two sites - not the tenant-wide Files.ReadWrite.All.
  4. 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.
  5. 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

Next steps

You can now inventory, protect, watch, and de-secret your workload identities, and contain one when it is compromised. Build outward:

workload-identitiesConditional-Accessfederated-credentialsIdentity-Protectionservice-principalsmanaged-identitiesMicrosoft-GraphEntra
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading