§ AC

A Federated Identity Credential Adds No Secret. Your Service Principal Persistence Alert Never Fires

Once an attacker has enough rights in an Entra tenant to touch application objects, the game stops being about stealing passwords and becomes about staying. The durable move isn’t a new global admin — those get reviewed. It’s grafting a credential onto an application or managed identity you already trust, because a workload identity walks straight past the conditional access and MFA you spent two years tuning for human sign-ins. Most shops know this and have a rule for it. The rule watches for Add service principal credentials. It’s a fine rule. It also covers exactly one of the ways to do the thing, and the way it misses leaves nothing behind that ever expires.

That way is a federated identity credential. A FIC on an app registration or a user-assigned managed identity trusts an external OpenID Connect issuer instead of a stored secret or certificate. It lives on the application object (for an app registration) or the managed-identity resource (for a UAMI) — not on the service-principal object directly, which Microsoft Graph won’t let you configure — even though the logon it ultimately produces is an ordinary service-principal sign-in. No passwordCredentials. No keyCredentials. Nothing in the object that a 90-day rotation report or a “secret expiring soon” dashboard will ever surface. An attacker who can write to the application object stands up a minimal OIDC endpoint they control, points a federated credential’s issuer at it, and from then on mints their own signed JWTs to authenticate as that service principal. The app’s permissions become theirs, and the credential doesn’t age out because there’s no credential in the classical sense — just a trust relationship to an issuer.

The credential that isn’t a secret

The three fields that matter are issuer, subject, and audience. Issuer is the OIDC provider URL Entra will trust. Subject is an arbitrary string the incoming token has to carry. Audience is api://AzureADTokenExchange — in global commercial Entra that’s the value the platform expects, and in practice the only one you’ll see; sovereign clouds substitute their own constant (api://AzureADTokenExchangeUSGov for US Gov, api://AzureADTokenExchangeChina for China operated by 21Vianet). That’s it. Workload identity federation was built so GitHub Actions and GitLab pipelines and AWS workloads could authenticate to Azure without a secret sitting in a CI variable, which is a genuinely good idea and why FICs exist at all. The same design that removes the secret from your build pipeline removes it from the attacker’s persistence, too.

Two properties make this uglier than the classic secret-add for a defender. First, a FIC-authenticated sign-in is a workload identity flow, so the interactive conditional access grammar — device compliance, MFA, sign-in risk — mostly doesn’t apply. You can gate workload identities with Conditional Access for workload identities, but that’s a separate policy set, licensed separately, and if you haven’t deployed it the FIC login sees none of your human-facing controls. Second, because it’s OIDC federation, the actual authentication happens against the attacker’s issuer. Entra validates a JWT signature against a JWKS endpoint the attacker hosts and issues an access token. The “auth event” you’d want to catch is happening partly outside your tenant.

You still get a sign-in log entry when the token gets exchanged, but how useful it is depends on the object. An app-registration FIC sign-in is well-instrumented: the service principal sign-in log carries a FederatedCredentialId and a federatedIdentityCredential credential type you can pivot on. A user-assigned managed identity FIC sign-in is thinner — the managed-identity sign-in logs (AADManagedIdentitySignInLogs) expose no equivalent federated-credential id, and often not even a source IP, so you can see that the UAMI authenticated but not easily which trust it used. Either way the sign-in is worth alerting on. But the write that created the persistence — the FIC being added — is the event you actually want, because it fires once and early, before any token exchange, while the secret-based world trained everyone to watch the credential-add.

Two objects, two log sources, one blind spot

Here’s the part that breaks naive detections. A FIC on an app registration logs to the Entra ID audit log as operation Update application, with the tell buried in modifiedProperties under the display name FederatedIdentityCredentials. Not Add service principal credentials. Different operation, different object. If your SIEM correlation rule keys on the string “Add service principal credentials” — and a lot of secret-centric rules did, including older stock Elastic and Sentinel content — the FIC add sails past because the operation name doesn’t match. Current Elastic content has since added FIC-specific rules (an issuer-modification rule on Update application, and a federated-credential sign-in rule — both cited below); the live gap is the many detections that predate them or were never moved off the secret-add operation.

A FIC on a user-assigned managed identity is worse. That write is an Azure Resource Manager control-plane operation: Microsoft.ManagedIdentity/userAssignedIdentities/federatedIdentityCredentials/write. It does not appear in the Entra ID audit log at all. It’s in the Azure Activity log. If your identity detections ingest Entra audit and sign-in logs but your team treats Azure Activity as a cloud-ops concern that lands in a different index (or a different tenant’s Log Analytics workspace, or nowhere), the managed-identity FIC is invisible to the people whose job is to catch persistence. The permission to do it isn’t even an Entra role — Contributor or Managed Identity Contributor on the subscription is enough. Your PIM review of directory roles won’t show the actor who can do this.

So you have one technique, two objects, two log sources, and a standard detection built for a third operation name on a fourth object. That’s the shape of the gap.

What the detection actually looks like

Split it, because the two objects live in different data.

For app registrations, hunt the Entra audit log for Update application where a modified property display name is FederatedIdentityCredentials. In a Sentinel-flavored query that’s roughly:

AuditLogs
| where OperationName == "Update application"
| mv-expand tr = TargetResources
| mv-expand mp = tr.modifiedProperties
| where tostring(mp.displayName) == "FederatedIdentityCredentials"
| extend actor     = coalesce(tostring(InitiatedBy.user.userPrincipalName), tostring(InitiatedBy.app.displayName)),
         actorSpId = tostring(InitiatedBy.app.servicePrincipalId),
         srcIp     = tostring(InitiatedBy.user.ipAddress),
         app       = tostring(tr.displayName)
| project TimeGenerated, actor, actorSpId, srcIp, app, mp.oldValue, mp.newValue

Two warnings on that query. The oldValue/newValue blobs are JSON-in-a-string and they get truncated when the credential set is large, so parsing the issuer URL out cleanly fails often enough that you should alert on the presence of the change first and treat issuer extraction as enrichment, not as a filter condition. And InitiatedBy splits between .user and .app depending on whether a human or another service principal made the change; a rule that only reads .user.userPrincipalName drops every FIC added by an already-compromised automation identity, which is exactly the chain you’re worried about. Read both — the coalesce above is the minimum.

For managed identities, you need Azure Activity ingested. Query the resource provider operation:

AzureActivity
| where OperationNameValue =~ "MICROSOFT.MANAGEDIDENTITY/USERASSIGNEDIDENTITIES/FEDERATEDIDENTITYCREDENTIALS/WRITE"
| where ActivityStatusValue in~ ("Succeeded", "Success") or ActivityStatus in~ ("Succeeded", "Success")
| project TimeGenerated, Caller, CallerIpAddress, _ResourceId, Properties

That status field is worth a second look before you ship the rule: the AzureActivity schema carries both a legacy ActivityStatus (“Succeeded”/”Failed”) and the newer ActivityStatusValue, and depending on how your workspace collects Activity logs the success value can arrive as either “Succeeded” or “Success” in either column. Use case-insensitive matches across both, or pin it to whatever your own workspace actually emits — a == "Success" that silently never matches is worse than no rule, because it reads as covered.

Volume: in a tenant that doesn’t use workload identity federation at all, both of these are near-zero events, which makes them beautiful high-signal detections — a single hit is worth a phone call. In a tenant with an active platform team pushing GitHub OIDC everywhere, the app-registration query can produce a handful to low tens of events a week during a federation rollout, and the managed-identity query tracks however aggressively your Terraform is provisioning UAMIs. Neither is a firehose. This is not a detection you tune down for volume; it’s one you tune for attribution.

Where the false positives come from

Every real hit here has a mundane twin. A platform engineer migrating a pipeline from GitHub to GitLab changes the issuer on a legitimate app and it looks identical to an attacker repointing issuer at their own OIDC endpoint. Elastic’s own rule notes call this out, and they’re right to. The first week of running this, your queue is going to be CI/CD work, not adversaries.

The tuning that actually helps isn’t a threshold, it’s an inventory. Build an allowlist of expected issuers: https://token.actions.githubusercontent.com, your GitLab instance’s issuer URL, the OIDC issuer URLs for any AWS or Google workloads you federate. Entra’s own issuer is a special case, not a generic allowlist row: workload identity federation does not accept Entra ID-issued tokens for the ordinary external-IdP flow, so a bare https://login.microsoftonline.com/<tenant-id>/v2.0 issuer should not be waved through as “cross-tenant” by reflex. The one sanctioned use is the managed-identity-as-a-federated-credential pattern (an app trusting a specific managed identity, now GA for cross-tenant access) — and that’s pinned by its subject, the managed identity’s object id, not by the issuer alone. Treat a login.microsoftonline.com issuer as something to verify against that documented pattern, not to trust because the hostname looks like Microsoft. A new FIC whose issuer is on that list, added by a known automation principal, is noise. A FIC whose issuer is a bare Azure Websites URL, an ngrok-style hostname, a raw IP, or anything not in the allowlist is the alert. Subject is a second axis worth pinning: an issuer like token.actions.githubusercontent.com is trusted by every GitHub repo on earth, so the subject — something like repo:org/name:ref:refs/heads/main — is the thing that actually scopes trust to your workload. A new FIC on a known-good, allowlisted issuer whose subject doesn’t map to a workload you recognize still deserves a look. Issuer reputation does most of the work here; subject specificity and actor identity do the rest. If the initiator is a human admin account adding a FIC by hand — as opposed to your IaC service principal doing it in a pipeline run — that inversion of the normal pattern is itself the signal, because in a healthy shop humans don’t hand-add federated credentials, automation does.

The second tuning pass is the managed-identity Caller field. Azure Activity’s Caller is sometimes a UPN, sometimes an object ID, sometimes empty depending on the auth path, and CallerIpAddress on control-plane events is frequently an Azure-internal address when the call came from another Azure service rather than an operator. Don’t build impossible-travel logic on that IP; it’ll lie to you. Attribute on caller identity plus the target UAMI’s blast radius instead. And note what the Activity event doesn’t carry: the federatedIdentityCredentials/write record names the actor, the operation, and the target UAMI, but not the issuer, subject, or entity type of the credential itself. To judge whether the new trust points somewhere unauthorized you have to enrich — pull the FIC’s issuer and subject from Azure Resource Graph or your IaC inventory — because the raw alert tells you a federated credential was written, not what it now trusts.

What changes the answer

Whether you use workload identity federation at all is the biggest lever. A shop that has never adopted it can treat any FIC creation as a critical, page-someone event and be done — the base rate is zero. A shop mid-rollout has to do the allowlist work before this detection is livable. Don’t skip straight to the allowlist version if you’re in the first camp; you’ll be suppressing the exact thing you want to see.

Tenant scale and log topology change it too. In a single-subscription tenant where Azure Activity already flows to the same Log Analytics workspace as your Entra logs, adding the managed-identity query is an afternoon. In an enterprise with a hundred subscriptions across management groups, each potentially routing Activity logs to different workspaces owned by different teams, the honest first task is confirming you even have the managed-identity FIC events somewhere queryable before you write a rule against them. GovCloud and commercial share the Microsoft.ManagedIdentity provider name, but differ on which workload identity federation features are GA and on some endpoint specifics, so validate the operation strings in your own tenant rather than trusting a blog — including this one — for the exact casing.

And app-registration ownership matters. In a flat tenant where a dozen people hold Application Administrator, the actor set for legitimate FIC changes is wide and your allowlist has to be generous. In a tenant where app registration is locked behind a privileged access workflow and only two automation principals ever touch application objects, the actor dimension alone catches almost everything, and you barely need the issuer allowlist.

Controls, and the point

This lands across several 800-53 families at once, which is part of why it slips through — no single control owner sees the whole path. It’s account management for non-human identities (AC-2, and specifically the service-account provisions people skip), authenticator management where the “authenticator” is a trust relationship rather than a secret (IA-5, IA-9 for service identification and authentication). It’s audit review that has to span two log sources instead of one (AU-2, AU-6). It’s configuration change control on application objects (CM-3, CM-5). And it’s the monitoring obligation itself (SI-4), which you cannot meet for the managed-identity variant if the relevant log never reaches the monitored index.

The correction is small and specific. Stop keying service principal persistence detection on the single operation name Add service principal credentials. Add Update application with a FederatedIdentityCredentials modified property, add the Azure Activity control-plane write for managed identities, and confirm — before you call it covered — that the Activity log for every subscription actually arrives somewhere you can query. The FIC doesn’t expire. Your detection for it shouldn’t depend on a log you forgot to ingest.

Sources