Entra ID workload identities for agent systems
Four identity options, one decision you're making whether you realize it or not. A working note on picking — and configuring — the right Entra ID workload identity for an AI agent in a Microsoft tenant.
Four identity options, one decision you're making whether you realize it or not. A working note on picking — and configuring — the right Entra ID workload identity for an AI agent in a Microsoft tenant.
client_secret; managed identity is the cheapest path when the agent runs on Azure compute.This is the deep dive on layer 01 of Making agent deployments pass security review — identity. Most agent projects in M365 / Azure tenants hit the same wall at the same moment: the security architect asks who the agent is acting as, and the answer is "a service account I made last Tuesday with Global Administrator and a 90-day-rotation policy." That answer is the audit-review killer. Workload identities are how you make a different one available.
An AI agent that reaches into Microsoft Graph, SharePoint, Dynamics, Cosmos, or any custom internal API is making API calls under some identity. That identity is what shows up in the downstream audit log — Entra sign-in logs, Graph audit logs, your own application's audit trail. There are exactly two outcomes:
agent-svc-prod did it" — a single machine account, frequently with broad scopes, that every agent action funnels through. Compliance review flags it. Incident response can't tell whose request triggered the action. Insurance carriers ask hard questions.Workload identities are the structural difference between those two outcomes. They've existed in Entra ID for years; what's changed is that agents now make many requests fast, against scoped data, and the audit shape of those requests is what determines whether the deployment survives review. Microsoft has explicitly named this problem space with Entra Agent ID as of 2026 — but the building blocks below predate it and remain the foundation for non-Microsoft-platform agents too.
Four identity shapes cover the realistic option space. The decision is not "which is best" — it's "which fits this call's downstream-attribution requirement." Multiple options can coexist in one agent system; the wrong one is the one that hides whose action it was.
option downstream identity RBAC inherits token lifetime audit clarity App-only (SP) agent app's SPN app role assignment 60–90 min "the agent did it" On-behalf-of (OBO) signed-in user's UPN user's directory 60–90 min "user X's agent did it" Federated cred (FIC) agent app's SPN app role assignment ~60 min (exchanged) "the agent did it" + external IdP trail Managed identity managed identity SPN identity role asgn ~24h (platform) "the resource did it"
Read the matrix this way. App-only is the agent acting as itself — fine when the agent operates on shared, non-user-scoped data (an alerting bot reading a tenant-wide telemetry table). OBO is the agent acting as a specific user — the only correct answer when the agent reads or writes user-scoped data. FIC is the agent runtime authenticating without a stored client secret — same downstream identity as app-only, but the credential bootstrap is a federated token exchange instead of client_secret. Managed identity is the platform-managed version of the agent's machine identity — applies when the agent runs on Azure compute.
The matrix above flattens nuance for legibility. Microsoft's own taxonomy folds applications, service principals, and managed identities into one umbrella — "workload identities" — with FIC as a credential type rather than an identity type. Mentally hold it both ways: four credential-and-attribution shapes, three Entra object types.
Federated identity credentials (FIC) are the path that doesn't get enough airtime in the agent-identity discussion. The premise is straightforward: an agent runtime outside Azure — a Vercel function, a GitHub Actions job, an on-prem Kubernetes pod, an AWS Lambda — can authenticate to an Entra app registration without storing a client secret. The runtime's native identity provider (GitHub's OIDC issuer, Kubernetes' service-account token, AWS's STS) issues a token; Entra trusts that token via a pre-configured trust relationship and exchanges it for an Entra access token. Workload identity federation is the canonical doc.
Why this matters for agents: the alternative is a long-lived client_secret sitting in Vercel env vars, GitHub repo secrets, or a Kubernetes Secret. Every one of those is a credential rotation problem with a non-zero leak surface. FIC eliminates the secret entirely. The cost is a one-time trust setup with three values you need to get right:
issuer — the OIDC issuer URL of the external IdP (e.g. https://token.actions.githubusercontent.com for GitHub Actions). Must match the iss claim in the runtime's token character-for-character. Trailing-slash drift is a common silent failure.subject — identifies the specific runtime workload. For GitHub Actions this is repo:<org>/<repo>:environment:<name> or repo:<org>/<repo>:ref:refs/heads/<branch>. For Kubernetes it's system:serviceaccount:<namespace>:<sa-name>. Wildcards are not supported anywhere — every workload variant needs its own FIC entry, up to the per-app limit of 20.audience — what Entra accepts in the token's aud claim. The recommended value is api://AzureADTokenExchange. Other clouds (USGov) have their own audience values.The trust-setup gotchas: subject mismatches fail silently (Entra rejects the exchange, returns nothing helpful), the issuer URL must exactly match what the IdP advertises in its OIDC discovery document, and FIC tokens issued by Entra ID itself are not eligible — federation is for external token issuers only. Microsoft documents the GitHub flow end-to-end in Authenticate to Azure from GitHub Actions by OpenID Connect; the same shape applies for Kubernetes, GCP, and SPIFFE.
The OAuth 2.0 on-behalf-of flow is the only correct answer for an agent that reads or writes user-scoped data. The shape is delegation: a user authenticates to your front-end (Teams app, Copilot Studio agent, custom web UI); the front-end gets an access token for the agent's API (token A); the agent's API exchanges token A — using its own client credentials plus token A as an assertion — for token B, which targets Microsoft Graph (or whatever downstream API the agent needs to call). Token B carries the user's identity.
The exchange happens at the Entra token endpoint with a specific shape — grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer and requested_token_use=on_behalf_of — and the resulting token B has the user's UPN in its claims. When the agent uses token B to call Graph, the resulting Graph audit-log entry attributes the action to the user, not the agent. The agent is a middle tier; the user is the principal.
Why API-key auth fails this case: an API key is the agent's identity. The downstream audit log reads "the agent's service principal did it" — and the user the agent was acting for is invisible. If your agent has 200 users and one of them triggers a sensitive Graph write, you cannot answer "which user" from the downstream log alone. OBO makes the answer the first claim in the token.
Practical OBO gotchas worth surfacing before you build:
Two short examples — one for each of the two paths agent developers actually write. A managed-identity call to Graph (the simplest path when the agent runs on Azure compute), and an OBO exchange (the path you write once and reuse in every user-triggered agent endpoint). Python because most agent runtimes use it; the MSAL surface is similar in C# and Node.
Managed identity → Graph (agent on Azure compute, app-only call):
# pip install azure-identity httpx from azure.identity import DefaultAzureCredential import httpx # DefaultAzureCredential picks up the managed identity automatically # when running on App Service / Functions / AKS with workload identity. credential = DefaultAzureCredential() token = credential.get_token("https://graph.microsoft.com/.default") resp = httpx.get( "https://graph.microsoft.com/v1.0/groups", headers={"Authorization": f"Bearer {token.token}"}, ) resp.raise_for_status()
On-behalf-of exchange (agent API endpoint, acting as the calling user):
# pip install msal httpx import msal, httpx # Confidential client app — agent's own app registration. app = msal.ConfidentialClientApplication( client_id=AGENT_CLIENT_ID, client_credential=AGENT_CLIENT_SECRET, # or FIC in production authority=f"https://login.microsoftonline.com/{TENANT_ID}", ) # user_token: the access token the front-end sent to this endpoint. # Its `aud` claim must be the agent's own API. result = app.acquire_token_on_behalf_of( user_assertion=user_token, scopes=["https://graph.microsoft.com/User.Read"], ) if "access_token" not in result: # Surface CA challenges (interaction_required) back to the caller. raise RuntimeError(result.get("error_description")) resp = httpx.get( "https://graph.microsoft.com/v1.0/me", headers={"Authorization": f"Bearer {result['access_token']}"}, )
The OBO snippet is the one most agent developers end up rewriting because the wizard-driven guides hand them an API-key copy-paste instead. The 15 lines above are the difference between "the agent did it" and "Samuel's agent, acting as Samuel, did it" in the Graph audit log.
A short pre-flight list specific to agent-identity work, ordered:
client_secret wherever the agent runtime supports it (GitHub Actions, Kubernetes, AWS, GCP, Vercel via OIDC). One trust setup, zero rotation.revokeSignInSessions" is correct.Identity is layer 01 of the five-layer pattern in Making agent deployments pass security review for a reason: get it wrong and every layer above it inherits the wrong audit shape. The four-way decision in this essay is the underlying machinery — Entra workload identities are how you separate "the agent did it" from "the user's agent did it" in the only log that matters during a compliance review.
None of these primitives are new. The OAuth flows are a decade old; managed identities have been GA for years; FIC has been GA since 2022. The novelty is that agents make many requests fast, against scoped data, and the audit shape of those requests is now the deployment-blocking question. The shape is fixable. The fix is upstream of the agent framework.