Service Principal Credential Adds Are the Entra Persistence Move Conditional Access Won’t See
An attacker who lands a token with application-management rights in your tenant — Application Administrator, Cloud Application Administrator, or a custom role carrying microsoft.directory/applications/credentials/update — has a quieter option than stealing another password. They add their own client secret or certificate to an app registration you already trust, and from that moment they authenticate as the application. No interactive sign-in. No MFA prompt, because service principals can’t do MFA. In most tenants, no Conditional Access evaluation either. The credential they added looks exactly like the credentials your own automation uses, sitting in the same keyCredentials and passwordCredentials collections, and it stays valid until someone notices an extra key with a two-year expiry that nobody can explain.
This is the workload-identity persistence move APT29 (Midnight Blizzard) leaned on against Microsoft itself, documented in their January 2024 responder guidance: compromise an identity, grant or abuse an OAuth application, then ride the application’s permissions. The technique is boring in the best way for an attacker. It uses a supported admin API, it generates one or two audit events that blend into a sea of legitimate secret rotations, and the resulting access doesn’t trip the user-focused detections most SOCs actually have wired up.
So the question is whether that audit event makes it into your index, and whether anything alerts on it.
The credential add, and why it sits outside your user controls
A service principal is the local instance of an application in your tenant. It carries permissions, delegated but more dangerously the application permissions like Graph Mail.Read, Directory.ReadWrite.All, or RoleManagement.ReadWrite.Directory that run without a signed-in user behind them. Authentication for that principal is a credential: a client secret (a password) or a certificate. Whoever holds a valid credential is the app.
The reason this is a persistence dream and not just another account takeover is the control surface. Workload identities skip the three things you rely on for users. They can’t perform MFA. They frequently lack a documented lifecycle owner, so nobody is going to notice a stale credential the way HR notices a departed employee. And their secrets have to live somewhere, so they leak in repos, pipeline variables, and laptop config. Microsoft’s Conditional Access documentation makes the same point: workload identities are higher-risk precisely because they can’t do MFA and have no formal lifecycle.
Add a credential to an over-permissioned first-party-looking app and you’ve got durable, MFA-immune access to whatever that app can touch. That’s the prize. The detection has to catch the add, because everything after it looks like the application doing its job.
What the event looks like in the log
The credential add lands in the Entra audit log under the ApplicationManagement category, Core Directory service. The activity names you care about:
Update application - Certificates and secrets management(the app registration object)Update Service principal/Add service principal credentials(the directory’s service principal object)
Note that adds can originate via either API surface — Graph /applications addPassword/addKey vs /servicePrincipals addPassword/addKey — and they surface under different activity names, so match on both or you’ll miss half the events. Both can carry a new KeyCredentials or PasswordCredentials value in modifiedProperties. If you’re shipping AuditLogs to Sentinel or the Log Analytics workspace via diagnostic settings — and you have to, because the Entra portal only keeps audit data ~30 days by default regardless of your P1/P2 tier, and extending it means streaming to Log Analytics — the starting query is roughly:
AuditLogs
| where Category == "ApplicationManagement"
| where OperationName has_any ("Add service principal credentials", "Update Service principal")
or OperationName has "certificates and secrets"
| extend actor = tostring(coalesce(InitiatedBy.user.userPrincipalName,
InitiatedBy.app.appId, InitiatedBy.app.displayName))
| extend actorId = tostring(coalesce(InitiatedBy.user.id, InitiatedBy.app.appId))
| extend actorApp = tostring(InitiatedBy.app.displayName)
| extend target = tostring(TargetResources[0].displayName)
| project TimeGenerated, OperationName, actor, actorId, actorApp, target, Result
Two silent failures to watch for. First, the Update application activity name is often rendered with an en dash (–), not an ASCII hyphen, and a copy-pasted string with the wrong dash will never match — which is why the query above matches on the substring certificates and secrets rather than the full literal. Second, OperationName values can be localized in non-English tenants, so verify the literal strings in your own workspace and prefer substring or modifiedProperties-based matching over hardcoded full strings.
Microsoft ships the NewAppOrServicePrincipalCredential analytic rule template for exactly this, and there are SigmaHQ equivalents if you’re on the Elastic side (the ingest mapping is messier there because the Entra audit schema doesn’t flatten cleanly into ECS, so you’ll be reaching into azure.auditlogs.properties.target_resources and the nesting fights you).
Here’s the part the rule template won’t do for you. The raw operation fires constantly. Every automated secret rotation, every Terraform azuread_application_password apply, every az ad app credential reset, every app onboarding generates one. Alert on the bare operation and you build alert fatigue fast — threshold tuning here is mandatory, not optional. The signal isn’t the add. It’s who added it and to what.
Tuning: the first week is rotation noise
The volume problem is real and you should expect it. In a tenant with mature automation, secret rotation alone can produce dozens of these events daily: CI/CD service connections cycling client secrets, cert-manager-style jobs renewing certificates, the Azure DevOps or GitHub Actions workload that re-stamps a secret on every pipeline run because someone wired it wrong two years ago and it still works.
First round of tuning, in order of payoff:
Baseline by actor. Build a set of the users and service principals that legitimately add credentials, and how often. The detection you actually want is “credential added by a principal that has never added one before, or hasn’t in 90 days.” In KQL that’s a join against a summarized lookback or a simple anomaly rule keyed on actorId. Better still, baseline the (actorId, targetAppId) pair — a known rotator suddenly stamping a credential onto a new high-privilege app is exactly the case a per-actor baseline lets through. This one change drops the volume more than anything else. Expect brand-new automation rollouts and bulk rotations to fire; exclude known change windows via CMDB/deployment tags where you can.
Weight by target privilege. A secret added to an app with application-level Graph permissions (anything with *.ReadWrite.All, RoleManagement.ReadWrite.Directory, AppRoleAssignment.ReadWrite.All, full mailbox read) is a different severity than a secret on some line-of-business app with delegated User.Read. Maintain a list of high-privilege app IDs as a watchlist (you should have this anyway for app governance, and you can populate it programmatically from Graph) and let it drive the alert tier. Don’t forget Exchange Online app-only permissions and Azure RBAC role assignments to service principals (Owner, User Access Administrator, Contributor) — those aren’t visible in the Entra app-permissions view but are just as much blast radius. The over-permissioned apps are where persistence pays off, so that’s where the false-positive tolerance should be near zero.
Then the off-hours and credential-type tells. A PasswordCredentials add at 0300 to an app that only ever used certificates is worth a look. So is a credential add immediately following a privileged role activation for the actor — the add-secret-right-after-getting-Global-Admin sequence shows up in public reporting on the Midnight Blizzard incident, and you can operationalize it by correlating to PIM activation events on the same actor within a short window.
False positives that survive all of that, in practice: managed identity backing operations and certain Microsoft first-party app maintenance events that show up with an app actor rather than a user. Test these in your own tenant before tuning on them — the audit surfacing is inconsistent across tenants. Don’t blanket-allowlist app actors to kill them — a stolen service principal token populates InitiatedBy.app, the exact field you’d be suppressing. Allowlist specific known initiator object IDs or appId GUIDs instead, never a user principal name or display name, and put a review/expiry cadence on the allowlist so it doesn’t become a permanent blind spot.
Correlate the add to the sign-in
The add is half the story. The credential gets a KeyId, and when it’s used, that key shows up in the service principal sign-in logs as ServicePrincipalCredentialKeyId. Those sign-ins are a separate log category — AADServicePrincipalSignInLogs in Log Analytics, enabled via the Entra ID diagnostic setting category for service principal sign-ins — and a lot of shops never turned that diagnostic setting on, so the table is empty and nobody noticed.
Turn it on, and make sure it streams to the same workspace as AuditLogs — if the two tables live in separate silos the KeyId join simply fails. Then you can pivot: take the KeyId from a suspicious add and look for sign-ins using it, the source IP, and the resources it requested:
AADServicePrincipalSignInLogs
| where ServicePrincipalCredentialKeyId == "<KeyId from the suspicious add>"
| project TimeGenerated, ServicePrincipalName, AppId, IPAddress,
ResourceDisplayName, ServicePrincipalCredentialKeyId
A credential added by a user in your tenant that then signs in from an IP in a hosting-provider range you’ve never seen is about as clean a signal as identity telemetry produces — though note the sign-in logs give you the IP, not the ASN, so the ASN/ISP attribution is an enrichment step you have to add yourself (a custom geolocation lookup table in the workspace, Sentinel’s TI/geoip enrichment, or an external feed like MaxMind). The catch is time skew and ingestion lag between the audit pipeline and the sign-in pipeline. They don’t arrive in lockstep, so don’t build the correlation with a tight time window or you’ll miss the join. Start with a couple of hours of slack and tune down based on observed lag — for active breach response you’ll want a tighter, explicit lookback.
The Conditional Access gap you pay extra to close
Here’s the thing that annoys me. You can scope Conditional Access to workload identities — block service principal sign-ins from outside a named location or trusted IP range, which would gut the “credential used from random ASN” attack. But CA evaluates the use of the credential at sign-in time; it never sees the addition of the credential, which is purely an audit event. And that sign-in-time gating requires Workload Identities Premium, a separate add-on on top of P1/P2. In a directory without those licenses, Microsoft’s docs are explicit that you can’t even create or modify CA policies scoped to service principals.
So the cleanest preventive control here is paywalled behind a SKU most tenants didn’t buy, and it only covers single-tenant service principals anyway — multi-tenant apps’ service principals and managed identities fall outside its scope. For everyone else, detection in the audit log isn’t a nice-to-have backstop. It’s the primary control. Plan accordingly, and don’t let an architecture review pretend CA covers your apps when the license says it doesn’t. The most durable prevention isn’t a SKU at all — it’s restricting who holds microsoft.directory/applications/credentials/update in the first place and gating those roles through PIM.
One more wrinkle on the horizon. Entra Agent ID, announced at Ignite 2025, gives AI agents first-class workload identities in the directory. Every agent you stand up is another non-human principal with credentials, permissions, and no MFA, the same object class, the same audit operations, the same blind spot, multiplied by however many agents your platform team spins up this year. Assuming agent identities share the service principal audit schema — worth confirming as the feature matures, and noting that if they use a different authentication flow your PasswordCredentials-specific logic may not apply — the detection you build for service principal credential adds is the detection you’ll need for agent identities. Build it once, build it right.
Where it maps
| Control | Why it applies |
|---|---|
| IA-5 | Authenticator management: credential lifetime, rotation, and the long-expiry secret that should have been flagged |
| IA-9 | Service identification and authentication: the workload identity is the thing authenticating, not a user |
| AC-2 / AC-6 | Account management and least privilege: over-permissioned app registrations are the blast radius |
| AU-6 | Audit review and analysis: the whole detection lives or dies on whether you ingest and alert on ApplicationManagement events |
| CM-5 | Access restrictions for change: who is allowed to modify app credentials at all |
| SR-3 | Supply chain controls and processes: third-party and OAuth apps you consented to are supply-chain trust; their credentials are your exposure |
The audit event is cheap to collect and the technique is well-documented. The failure mode isn’t sophistication, it’s that the event lands in a category nobody alerts on, fired by an actor type nobody scopes controls to, against an object class that can’t do MFA. Ingest AuditLogs and AADServicePrincipalSignInLogs, alert on credential adds keyed by actor rarity and target privilege, and correlate the KeyId forward into sign-ins. That gets you most of the way without the Premium SKU.
Sources
- Midnight Blizzard: Guidance for responders on nation-state attack (Microsoft Security Blog)
- Microsoft Entra security operations for applications (Microsoft Learn)
- Microsoft Entra Conditional Access for workload identities (Microsoft Learn)
- NewAppOrServicePrincipalCredential analytic rule (Azure-Sentinel, GitHub)
- Service principal sign-in logs (Microsoft Learn)
- What is Microsoft Entra Agent ID? (Microsoft Learn)
- Entra ID service principals in business email compromise schemes (Red Canary)