Identity for AI agents: workload identity, delegation and MCP authorization without shared keys

Workload identity tells you which pod is calling. AI agents need more: on whose behalf they act, and what they may do right now. A practical guide to agent identity with RFC 8693 token exchange, the MCP authorization spec, token vaults, and the managed options on AWS, Google Cloud and Microsoft Entra.

Đọc bằng tiếng Việt →

Every article in this series so far solved one problem: a workload proves who it is without a static key. That covers EKS Pod Identity and IRSA, GKE and AKS workload identity, federation across clouds, and IAM Roles Anywhere for machines outside any cloud.

AI agents break the assumption behind all of that. A normal workload runs code you wrote, so you know in advance which APIs it will call, and you give its role exactly those permissions. An agent runs a model that decides at runtime which tools to call, with arguments partly taken from untrusted text: emails, tickets, web pages, tool output. It usually acts for a specific person. If you give it a service account that can read every customer’s tickets, one prompt injection lets it do exactly that.

So agent identity is workload identity plus two more questions.

Three questions for every agent call: who is the agent (workload identity), on whose behalf is it acting (delegation with sub and act claims), and what may it do right now (audience-bound, scoped, short-lived token per tool)
Workload identity answers question 1. Delegation and per-tool tokens answer 2 and 3.

The anti-patterns we’re replacing

  • One API key per tool, in the agent’s environment. Every user gets the same power. The key never expires and ends up in logs, traces and sometimes the model’s context.
  • The agent forwards the user’s token everywhere. A token issued for your app is replayed against the ticket system, the CRM and an MCP server someone installed last week. Any of them can now reuse it against the others. The MCP spec calls this token passthrough and forbids it.
  • A “god” service account for the agent platform. Audit logs say agent-platform@ did everything. You can’t tell which user asked, which agent acted, or why.

The target is: the user stays the subject, the agent is recorded as the actor, and every tool sees a token minted only for it, with only the scopes this task needs, for minutes.

Question 1: the agent’s own identity

The agent is a workload, so start with everything from earlier in the series. On Kubernetes, run each agent type under its own ServiceAccount and federate it to the cloud with Pod Identity, IRSA, GKE Workload Identity or Entra Workload ID. Use SPIFFE/SPIRE if you need a cross-platform ID. Never share one identity across different agents.

The three big platforms now also have identities designed specifically for agents:

AWS: AgentCore Identity Google Cloud: Agent Identity Microsoft Entra Agent ID
What the agent is A workload identity with its own ARN, created automatically for agents on AgentCore Runtime A per-agent principal with a SPIFFE ID, e.g. spiffe://agents.global.org-ORG_ID.system.id.goog/resources/… An agent identity: a special service principal created from an agent identity blueprint
Credentials None in your code. Runtime hands the agent a workload access token that binds agent and user X.509 cert valid 24 h, rotated automatically. Google access tokens are bound to that cert (mTLS) The agent identity has no credentials of its own. The blueprint holds them and gets tokens for it via federated credentials
Acting for a user GetWorkloadAccessTokenForJWT takes the user’s JWT and checks iss, signature and expiry OAuth tokens for external tools through the auth manager User tokens where the subject is the user and the actor is the agent identity
Third-party SaaS tokens Token vault, KMS-encrypted, 2LO (M2M) and 3LO (USER_FEDERATION) Auth manager (credential vault) Blueprint permissions, OBO flows
Notable governance IAM can deny the unverified …ForUserId path Can’t be impersonated, no SA keys; redeploying gives a new principal Sponsor (accountable human), Conditional Access and disable per blueprint

A few details that matter in practice:

  • Google: in IAM bindings you use the principal:// form of the same identifier. The trust domain is agents.global.org-ORGANIZATION_ID.system.id.goog, or agents.global.proj-PROJECT_NUMBER.system.id.goog for projects without an organization. Agent identities aren’t deleted with the agent, and a redeployed agent gets a new principal. Automate the IAM bindings (Terraform, Config Connector), or every redeploy breaks access and leaves stale grants behind.
  • AWS: prefer GetWorkloadAccessTokenForJWT. The …ForUserId variant accepts any string as the user, so restrict it with IAM, or deny it outright if you always have a JWT:
{
  "Statement": [{
    "Sid": "DenyForUserIdAccess",
    "Effect": "Deny",
    "Action": "bedrock-agentcore:GetWorkloadAccessTokenForUserId",
    "Resource": "arn:aws:bedrock-agentcore:REGION:ACCOUNT_ID:workload-identity-directory/default"
  }]
}
  • Entra: the blueprint is the unit of control. It’s the “kind” of agent, e.g. “Sales Assistant”, and every deployed instance gets its own agent identity from it. One Conditional Access policy or one disable on the blueprint covers every instance. Agent identities are single-tenant, even when the blueprint is multitenant.

Question 2: on whose behalf, with token exchange

For self-hosted agents, or to stay portable across clouds, the standard tool is OAuth 2.0 Token Exchange (RFC 8693). The agent sends two tokens to your STS (Keycloak, Okta, Entra, an internal STS):

  • the subject token: the user’s token, proving who asked, and
  • the actor token: the agent’s own workload token, proving which agent is asking.

It gets back a new token for one target with narrow scopes:

curl -s https://sts.example.com/oauth2/token \
  -d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
  -d subject_token="$USER_ACCESS_TOKEN" \
  -d subject_token_type=urn:ietf:params:oauth:token-type:access_token \
  -d actor_token="$(cat /var/run/secrets/tokens/agent-token)" \
  -d actor_token_type=urn:ietf:params:oauth:token-type:jwt \
  -d resource=https://mcp.tickets.example.com \
  -d scope=tickets:read

The actor_token here is a projected ServiceAccount token with the STS as its audience, the same mechanism IRSA and GKE use. The issued token carries the delegation chain in the standard act claim:

{
  "iss": "https://sts.example.com",
  "sub": "user-8f2c",
  "aud": "https://mcp.tickets.example.com",
  "scope": "tickets:read",
  "exp": 1792396800,
  "act": { "sub": "system:serviceaccount:agents:support-agent" }
}
The user signs in to the app; the agent runs with its own workload identity; it exchanges the user's token plus its own token at the STS for a short-lived token with sub=user, act=agent and aud=the MCP server; the MCP server validates the audience and uses its own credentials or a token vault for downstream APIs instead of passing the token through
The effective permission is the intersection: what the user may do, what this agent may do, and what this tool's scopes allow.

The STS is where policy lives. Before it issues the token, it checks three things: is this agent allowed to act for this user, on this resource, with these scopes? A support agent can read tickets for the signed-in user, but can never get billing:refund, however convincingly an email asks for it.

Question 3: what it may do right now, with MCP authorization

Most agent tools are now MCP servers. The MCP authorization spec (current revision 2026-07-28) is plain OAuth 2.1 with a few rules that are exactly what agents need:

The client gets a 401 with resource_metadata, reads the protected resource metadata to find the authorization server, discovers its metadata, identifies itself with a Client ID Metadata Document URL, runs authorization code with PKCE and the resource parameter, validates iss, and calls the MCP server with an audience-bound token; a 403 insufficient_scope triggers step-up
Discovery, client identity, audience-bound tokens and step-up. Each one is a standard RFC.
  1. Discovery. The MCP server MUST publish Protected Resource Metadata (RFC 9728) and return 401 with WWW-Authenticate: Bearer resource_metadata="…". It SHOULD include a scope hint. The client finds the authorization server from that metadata.
  2. Client identity. Clients SHOULD use Client ID Metadata Documents. The client_id is an HTTPS URL that the authorization server fetches. Dynamic Client Registration is now deprecated and only kept for backwards compatibility.
  3. Audience binding. The client MUST send the resource parameter (RFC 8707) with the MCP server’s canonical URI. The server MUST reject tokens not issued for it.
  4. No passthrough. The MCP server MUST NOT forward the token it received. For downstream APIs it needs a separate token issued for that API: its own workload identity, another token exchange, or a token vault.
  5. Step-up. On a missing scope the server returns 403 with error="insufficient_scope" and the scopes this operation needs. The client re-authorizes with the union of old and new scopes. For a user-facing agent, that is your human-in-the-loop moment for risky actions.
  6. Mix-up protection. Clients record the expected issuer and validate iss in the authorization response (RFC 9207).

The Protected Resource Metadata for the tickets server:

{
  "resource": "https://mcp.tickets.example.com",
  "authorization_servers": ["https://sts.example.com"],
  "scopes_supported": ["tickets:read"],
  "bearer_methods_supported": ["header"]
}

scopes_supported is the minimum for basic use. tickets:write is requested later through step-up, only when the agent actually tries to write.

On the server side, the checks are short. Here they are in Python with PyJWT:

import jwt

RESOURCE = "https://mcp.tickets.example.com"
jwks = jwt.PyJWKClient("https://sts.example.com/.well-known/jwks.json")

def authorize(bearer: str, needed_scope: str) -> dict:
    key = jwks.get_signing_key_from_jwt(bearer).key
    claims = jwt.decode(
        bearer, key, algorithms=["RS256", "ES256"],
        audience=RESOURCE,                 # reject tokens minted for anyone else
        issuer="https://sts.example.com",
        options={"require": ["exp", "sub", "aud"]},
    )
    if needed_scope not in claims.get("scope", "").split():
        raise InsufficientScope(needed_scope)   # → 403 + WWW-Authenticate
    return {
        "user": claims["sub"],
        "agent": claims.get("act", {}).get("sub"),  # log both, always
    }

Enterprise SSO for MCP: ID-JAG

Users in a company shouldn’t click “Allow” for every MCP server. The Enterprise-Managed Authorization extension, stable in the MCP auth extensions repository, lets the company IdP decide instead:

  1. The client exchanges the user’s ID token at the IdP (RFC 8693) for an ID-JAG (Identity Assertion JWT Authorization Grant). It sends requested_token_type=urn:ietf:params:oauth:token-type:id-jag, with audience set to the MCP authorization server’s issuer.
  2. The IdP applies admin policy, i.e. whether this app may reach this MCP server for this user, and returns a short-lived ID-JAG. The example in the extension uses expires_in: 300.
  3. The client presents it to the MCP authorization server with the JWT bearer grant (urn:ietf:params:oauth:grant-type:jwt-bearer, RFC 7523) and gets an access token bound to that MCP server.

Support is advertised as urn:ietf:params:oauth:grant-profile:id-jag in authorization_grant_profiles_supported. For fully autonomous agents, with no user in the loop, there is a separate client credentials extension, still a draft at the time of writing.

Third-party SaaS: token vaults, not environment variables

The agent eventually needs Google Drive, GitHub, Slack or Jira as the user. Don’t build your own refresh-token store inside the agent. Use a vault that releases a token only to a caller that proves agent + user:

from bedrock_agentcore.identity.auth import requires_access_token

@requires_access_token(
    provider_name="google-provider",
    scopes=["https://www.googleapis.com/auth/drive.metadata.readonly"],
    auth_flow="USER_FEDERATION",          # 3-legged: the user consents once
    on_auth_url=lambda url: stream_to_caller({"authorization_url": url}),
    force_authentication=False,
    callback_url="https://agent.example.com/oauth/callback",
)
async def list_drive_files(*, access_token: str):
    ...

That’s AgentCore Identity. Google’s Agent Identity auth manager and an internal Vault-backed broker follow the same pattern:

  • The token is injected into the tool function, not into the prompt. The model never sees it, so it can’t leak it.
  • The vault key is (agent, user, provider). Agent A can’t read agent B’s tokens, and user X’s consent is never used for user Y.
  • Revoking consent happens in one place.

Guardrails checklist

  • One identity per agent type, never shared and never a human’s account. On Entra, use one blueprint per agent kind.
  • No static secrets in agent pods: workload identity for the cloud, token exchange or a vault for everything else.
  • Tokens stay out of the model’s context, logs and traces. Inject them in the tool layer and redact Authorization headers.
  • Every tool token is audience-bound (resource / aud) and short-lived. Minutes for the exchange, not hours.
  • Least scopes first, with step-up and explicit user confirmation for write, delete, payment and send-email actions.
  • Audit both identities: sub (user) and act (agent) on every tool call, correlated with the agent’s trace ID.
  • Kill switches at three levels: one agent instance (disable the identity), one agent kind (blueprint or role), one tool (remove the MCP server from the authorization server’s allowed audiences).
  • Lifecycle: an owner or sponsor for every agent identity, and a cleanup of IAM bindings when agents are redeployed or retired.

Wrap-up

Workload identity got us out of static keys for services. Agents need one more step, because the code path is decided by a model and the authority comes from a person. Give the agent its own identity, keep the user as the subject with token exchange, and make every tool call carry a narrow, audience-bound, short-lived token. MCP’s authorization spec, RFC 8693 and the managed agent identities on AWS, Google Cloud and Entra already give you the parts. The work is in wiring them together, with policy at the STS and audit on both sub and act.

Sources

Comments