Managed identities & SP escalation
Kill client secrets; watch credential injection.
A client secret is a spare key you had cut for a contractor and then put in the mail. It opens the door for anyone holding it, it keeps working long after the job is finished, and you have no real idea how many copies got made along the way. A managed identity is the opposite kind of thing: a hotel keycard the front desk reissues every few hours, that works only while you are actually checked in, and that never leaves the building.
This lesson kills the long-lived key, shows you the zero-secret option for workloads that live outside Azure, and then walks the exact escalation path that opens up anywhere an application credential still lingers. You will add a credential to a privileged identity the way an attacker would, watch it work, and then build the detection and the guardrails that shut it down.
Managed Identities: An Identity Azure Owns For You
A managed identity is a service principal (an application's own login account inside your directory) that Microsoft Entra ID creates, owns, and hands tokens to on request. Entra ID is Microsoft's identity service, the thing that used to be called Azure Active Directory, or Azure AD; Microsoft renamed it in 2023, though the CLI still says az ad for historical reasons. Because Entra mints and rotates the credential for you, on its own schedule and inside its own systems, there is no secret to drop into your code, your pipeline variables, or a config file. Nothing to hold means nothing to leak.
There are two flavours. A system-assigned identity is born on a single resource and dies with it (one to one, tidy: delete the virtual machine and its identity goes too). A user-assigned identity is a free-standing object you make once and bolt onto many resources, which is what you want when several workloads share the same role, or when the identity has to outlive any one machine.
The runtime trick is the same either way. Your code asks a link-local address, 169.254.169.254, for a token. That address is the Instance Metadata Service, or IMDS, a small endpoint that answers only from inside the Azure resource itself, like an internal phone line with no number you can dial from the street. Azure replies with a short-lived bearer token (a token good for whoever holds it, valid for a limited window that Azure keeps refreshed for you before it lapses). No secret ever touches disk. Your job is to grant that identity one narrow role, at the narrowest scope: one vault, not the whole subscription.
# Create a user-assigned managed identity: a standalone, shareable Entra identity.az identity create -g rg-payments -n payments-mi
{"clientId": "3a9c1e77-4b2f-4c6a-9d1e-8f0a2b3c4d5e","id": "/subscriptions/8d1e.../resourceGroups/rg-payments/providers/Microsoft.ManagedIdentity/userAssignedIdentities/payments-mi","location": "eastus","name": "payments-mi","principalId": "6f2b0d4a-1c3e-4a5b-8d7f-2e9a0b1c2d3e","resourceGroup": "rg-payments","tenantId": "9f1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d","type": "Microsoft.ManagedIdentity/userAssignedIdentities"}
That principalId is the object identifier you hand to Azure RBAC (role-based access control, the permission system for your subscriptions and resources). Capture it, then grant exactly one vault's secrets and nothing else.
PRINCIPAL=$(az identity show -g rg-payments -n payments-mi --query principalId -o tsv)SUB=$(az account show --query id -o tsv)# Grant ONE vault's secrets. --assignee-principal-type stops RBAC racing Entra replication.az role assignment create \--assignee-object-id "$PRINCIPAL" \--assignee-principal-type ServicePrincipal \--role "Key Vault Secrets User" \--scope "/subscriptions/$SUB/resourceGroups/rg-payments/providers/Microsoft.KeyVault/vaults/payments-kv"# Verify: exactly one assignment, one vault, nothing broader.az role assignment list --assignee "$PRINCIPAL" --all -o table
Principal Role Scope------------------------------------ ---------------------- -------------------------------------------6f2b0d4a-1c3e-4a5b-8d7f-2e9a0b1c2d3e Key Vault Secrets User /subscriptions/8d1e.../vaults/payments-kv
That --assignee-principal-type ServicePrincipal flag earns its keep. A brand-new identity may not have finished replicating across Entra when your next command runs, and without the hint the assignment fails with a PrincipalNotFound error that sends you chasing a problem that does not exist. Give the hint and Azure skips the lookup. Plan for the other limits too: tokens are audience-scoped (a Key Vault token is not a Storage token, so you request the right resource), the identity works only from inside its resource, and deleting a resource without cleaning up its role assignment leaves an orphaned grant pointing at a principal nobody can name anymore.
No Secret At All: Workload Identity Federation
Managed identities exist only for things running inside Azure. For everything else, a GitHub Actions runner, a GitLab pipeline, a workload in another cloud, a Kubernetes pod on your own hardware, there is a second zero-secret option called workload identity federation. It works the way a bar checks your ID instead of keeping a guest list. You register an app in Entra, then attach a federated credential that says: trust any login carrying a valid token from this one issuer, whose subject claim is exactly this string. At run time the outside system presents its own OpenID Connect token (OpenID Connect, or OIDC, is a standard way for one system to vouch for an identity to another), Entra checks the issuer and the subject, and if they match it swaps that token for a real Azure token. Nothing is stored in the pipeline, so there is no secret to steal, rotate, or dig out of git history a year later.
# Register an app, add OIDC trust: zero stored secrets for pipelines outside Azure.APP_ID=$(az ad app create --display-name payments-ci --query appId -o tsv)az ad sp create --id "$APP_ID" -o noneaz ad app federated-credential create --id "$APP_ID" --parameters '{"name": "gh-main","issuer": "https://token.actions.githubusercontent.com","subject": "repo:acme/payments:ref:refs/heads/main","audiences": ["api://AzureADTokenExchange"]}'# Verify the trust is pinned to exactly one repo + one ref (NOT refs/heads/*).az ad app federated-credential list --id "$APP_ID" \--query "[].{name:name, subject:subject}" -o table
Name Subject------- --------------------------------------gh-main repo:acme/payments:ref:refs/heads/main
pull_request context (repo:acme/payments:pull_request), and every pull request that runs presents that one string, so the trust is no longer tied to code you control. Bind it to a branch or tag name an outsider can create in the repo and you have the same hole. Pin the subject to one repository and one protected ref (repo:acme/payments:ref:refs/heads/main), or to a named, protected environment, and give the app only the roles that one pipeline actually needs.The Escalation: Credential Injection
Here is where the danger still lives. A locksmith can cut a fresh key for a lock without ever touching the original, and in Entra anyone who can manage an application's credentials is that locksmith. They add a new client secret or certificate to an application that already exists, sign in with the new key, and inherit everything that application can do. This is credential injection, the classic Entra privilege escalation. Two directory roles carry the power, Application Administrator and Cloud Application Administrator, and so does any owner of the specific app.
Notice the boundary it crosses. That administrator can hold zero Azure RBAC on your production subscription. Not Owner, not Contributor, not Reader. But if a service principal in the directory holds Owner on that subscription, the admin injects a secret into it and logs in as it. Control of the directory turns into control of your resources, through the service principal sitting in the middle. The two planes, directory and resource, are meant to be separate. The service principal is the bridge between them.
# ATTACKER holds Application Administrator, or owns the target app.# Append a NEW secret to the app registration behind a privileged service principal.az ad app credential reset --id "$VICTIM_APP_ID" --append --years 1
{"appId": "c1a2b3c4-d5e6-4f70-8a91-b2c3d4e5f607","password": "Zx8Q~R3aL.g3nerated-secret_Value123","tenant": "9f1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d"}
That password is a working bearer credential, valid immediately. Sign in as the service principal and read what you now hold.
az login --service-principal -u "$VICTIM_APP_ID" -p 'Zx8Q~R3aL.g3nerated-secret_Value123' --tenant "$TENANT"az role assignment list --assignee "$VICTIM_APP_ID" --all -o table
Principal Role Scope------------------------------------ ----- ------------------------------------8e2d5b9c-3f1a-4c7e-9b0d-6a4f2e8c1d3b Owner /subscriptions/<prod-subscription>
az ad app credential reset without --append and it replaces every existing credential on the app. The real workload stops authenticating, something breaks in production, and on-call gets paged, which announces the intrusion. With --append the attacker's secret is added while all the legitimate ones keep working, so nothing breaks and nobody is paged. The silent version is the one that gets used. If you ever run a genuine credential rotation, remember that the plain reset is destructive and schedule it like one.See It: Hunt Credential Injection
You cannot stop every credential-add, so you have to be able to see them. Every secret or certificate added in the directory lands in the Entra audit log. The catch is that it shows up under two different operation names, depending on whether the credential went onto the application object or its service principal object: Add service principal credentials and Update application – Certificates and secrets management. A rule watching only one of them is half-blind. There is a sharper trap underneath that. The second name has turned up in real tenants with two different dash characters, a plain hyphen in some records and a longer en-dash in others, so an exact string match can sail right past the event. Match on a fragment that sits to one side of the dash, and which character was written stops mattering.
# Ad-hoc hunt via Microsoft Graph: recent service-principal credential adds, with the actor.az rest --method get \--url "https://graph.microsoft.com/v1.0/auditLogs/directoryAudits?\$filter=activityDisplayName eq 'Add service principal credentials'&\$top=20" \--query "value[].{time:activityDateTime, actor:initiatedBy.user.userPrincipalName, target:targetResources[0].displayName, result:result}" \-o table
Time Actor Target Result-------------------- ----------------- ---------------- -------2026-07-14T02:11:07Z [email protected] payments-prod-sp success
Codify the same idea as a Microsoft Sentinel scheduled analytics rule, so a new secret on any privileged app raises an incident within minutes, enriched with the actor and the target. The operator that does the work is has_any, which matches whole words rather than raw substrings. KQL (Kusto Query Language, the query language for this data) breaks text into terms wherever it hits a space or a punctuation mark, then checks whether your search phrase shows up as a run of those terms. Feed it the two dash-free fragments and it catches both operation names at once. It also steps around the dash problem for free: a hyphen and an en-dash are both punctuation, so KQL splits on either one, and your fragment sits entirely on one side of the split. This is the shape published Sentinel detections for this technique use.
// Microsoft Sentinel scheduled analytics rule (Kusto Query Language, KQL).// has_any matches whole words, not raw substrings. The two dash-free fragments// catch BOTH operation names and survive the hyphen / en-dash inconsistency,// because KQL splits terms on either dash character.AuditLogs| where OperationName has_any ("Add service principal credentials", "Certificates and secrets management")| where Result =~ "success"| extend actor = tostring(InitiatedBy.user.userPrincipalName)| extend targetApp = tostring(TargetResources[0].displayName)| project TimeGenerated, actor, OperationName, targetApp, CorrelationId
For inventory, az ad app credential list and az ad sp credential list show what credentials exist, their key IDs and start and end dates, but never the secret value. Use them to spot a credential you did not create.
# Metadata only: which credentials exist on this app? (never the secret value)az ad app credential list --id "$VICTIM_APP_ID" \--query "[].{keyId:keyId, name:displayName, start:startDateTime, end:endDateTime}" -o table
KeyId Name Start End------------------------------------ ---------- -------------------- --------------------7c9d0e1f-3a2b-4c5d-8e6f-0a1b2c3d4e5f ci-signing 2025-11-02T00:00:00Z 2026-11-02T00:00:00Z2e3f4a5b-6c7d-4e8f-9a0b-1c2d3e4f5a6b 2026-07-14T02:11:05Z 2027-07-14T02:11:05Z
The second row is the injected key. No display name, created at 02:11 on the same night your hunt flagged the actor, valid for a full year. A credential nobody meant to make, sitting quietly next to a legitimate one. That is the shape you are looking for.
Remove The Credential, Gate The Role
Detection is the backstop. The real win is deleting the whole credential class, and there are three controls, from broad to sharp. The first is an app management policy that forbids password credentials on new apps across the tenant, which pushes teams onto federation or managed identities by construction. Set restrictForAppsCreatedAfterDateTime to today and every app registered from now on is blocked from getting a client secret at all.
# Tenant guardrail: block client-secret creation on apps registered from today onward.az rest --method patch \--url "https://graph.microsoft.com/v1.0/policies/defaultAppManagementPolicy" \--headers "Content-Type=application/json" \--body '{"isEnabled": true,"applicationRestrictions": {"passwordCredentials": [{"restrictionType": "passwordAddition","state": "enabled","restrictForAppsCreatedAfterDateTime": "2026-07-22T00:00:00Z"}]}}'# Verify it took (the PATCH itself returns HTTP 204 with no body).az rest --method get --url "https://graph.microsoft.com/v1.0/policies/defaultAppManagementPolicy" \--query "applicationRestrictions.passwordCredentials" -o json
[{"restrictionType": "passwordAddition","state": "enabled","maxLifetime": null,"restrictForAppsCreatedAfterDateTime": "2026-07-22T00:00:00Z"}]
The second control makes the credential-managing directory roles eligible through Privileged Identity Management, or PIM, instead of permanently active. PIM turns a standing right into one you have to switch on: activation needs multi-factor authentication (a second proof of identity beyond the password), expires on a timer, and writes a logged justification. Nobody injects a secret unnoticed because nobody is holding the role at rest.
# Make Application Administrator ELIGIBLE (not standing) for your app-admin group via PIM.# principalId is that role-assignable group; 9b895d92-... is the well-known# template ID for Application Administrator.az rest --method post \--url "https://graph.microsoft.com/v1.0/roleManagement/directory/roleEligibilityScheduleRequests" \--headers "Content-Type=application/json" \--body '{"action": "adminAssign","principalId": "4d5e6f7a-8b9c-4d0e-9f1a-2b3c4d5e6f70","roleDefinitionId": "9b895d92-2cd3-44c7-9d02-a6ac2d5ea5c3","directoryScopeId": "/","justification": "App admins eligible-only, no standing credential-management rights","scheduleInfo": { "expiration": { "type": "afterDuration", "duration": "P180D" } }}'
{"id": "b7e6d5c4-9a8b-4c7d-8e6f-1a2b3c4d5e6f","status": "Provisioned","action": "adminAssign","principalId": "4d5e6f7a-8b9c-4d0e-9f1a-2b3c4d5e6f70","roleDefinitionId": "9b895d92-2cd3-44c7-9d02-a6ac2d5ea5c3","directoryScopeId": "/","createdDateTime": "2026-07-22T09:20:41.55Z"}
The third control is for the secret you cannot avoid: keep it in Key Vault with a short lifetime, a tight scope, and real rotation, never in app settings and never in a repo. The cost of all three is honest and small. Federation is a one-time trust setup. PIM adds an activation step your admins will grumble about the first week. The policy blocks a habit some teams lean on. In return you delete a whole category of durable, portable keys, the kind that sit in a pipeline log for two years until someone finds them.
az ad app credential reset --append rather than the same command without --append?az login --service-principal needs no such flag to authenticate.OperationName == 'Update application - Certificates and secrets management' (exact match, plain hyphen) and Result == 'success'. An attacker adds a secret to a privileged app, the event is in the audit log, yet no alert fires. Most likely cause?Identity is the first perimeter, and you have now closed the loudest gap in it. But a workload with a perfectly scoped managed identity can still be reached, probed, and used as a pivot if the network lets anything talk to it. Next you make the network default-closed, network security groups, application security groups, and no public management ports, so a compromised identity finds nowhere to go.
Try this
Run az identity create -g rg-payments -n payments-mi on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.
Takeaway
The trap worth remembering here: a loose federated subject is a front door. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.