CoursesAzure securityManaged identities & SP escalation

Managed identities & SP escalation

Kill client secrets; watch credential injection.

Expert35 min · lesson 3 of 15

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.

terminal
# Create a user-assigned managed identity: a standalone, shareable Entra identity.
az identity create -g rg-payments -n payments-mi
output
{
"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.

terminal
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
output
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.

terminal
# 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 none
az 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
output
Name Subject
------- --------------------------------------
gh-main repo:acme/payments:ref:refs/heads/main
A loose federated subject is a front door
Federation trusts whatever the subject claim matches, character for character, and that exactness cuts both ways. Bind the credential to a broad entry point, like the 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.
Which identity fits this workload?
Where does the workload run, and does it need a standing credential?
runs inside Azure
Managed identity
VM, Function, App Service, AKS pod: Azure mints and rotates the token, nothing to store
runs outside Azure
Workload identity federation
CI runner, another cloud, on-prem k8s: trade an OIDC token for an Azure token, pin issuer and subject
neither fits
Client secret (last resort)
Legacy or third-party app: vault it, scope it tight, alert on every credential add

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.

terminal
# 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
output
{
"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.

terminal
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
output
Principal Role Scope
------------------------------------ ----- ------------------------------------
8e2d5b9c-3f1a-4c7e-9b0d-6a4f2e8c1d3b Owner /subscriptions/<prod-subscription>
The --append flag is the quiet part
Run 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.

terminal
# 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
output
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.

detect-cred-injection.kql
// 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.

terminal
# 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
output
KeyId Name Start End
------------------------------------ ---------- -------------------- --------------------
7c9d0e1f-3a2b-4c5d-8e6f-0a1b2c3d4e5f ci-signing 2025-11-02T00:00:00Z 2026-11-02T00:00:00Z
2e3f4a5b-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.

terminal
# 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
output
[
{
"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.

terminal
# 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" } }
}'
output
{
"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.

Quick check
01Why does a managed identity remove the credential-leak risk that a client secret carries?
Correct — the secret never exists on your side, so there is nothing to copy, mail, or lose.
Incorrect — that is still a stored secret you must protect; a managed identity has none to begin with.
Incorrect — certificates can be copied, and the point is that Azure holds and rotates the credential for you so you never hold one.
Incorrect — managed identities are granted RBAC roles like any principal, which is exactly why you scope them tightly.
02In the credential-injection attack, why does the attacker use az ad app credential reset --append rather than the same command without --append?
Incorrect — append adds a credential and grants nothing; the service principal's existing roles are what get inherited.
Correct — append is the quiet path that avoids an outage and the alert an outage would trigger.
Incorrect — az login --service-principal needs no such flag to authenticate.
Incorrect — both paths are logged; append is about not breaking the app, not about hiding from the log.
03Your Sentinel rule fires on 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?
Incorrect — it records them; the gap is in your query, not in the log.
Incorrect — a completed credential add logs as success, so that filter is fine.
Correct — two operation names plus an inconsistent dash defeat exact-match, and matching on the dash-free fragments fixes both blind spots at once.
Incorrect — AuditLogs is exactly where these events live and are queried.

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.

Related