Skip to content

IV.6IV · The protocol layerdefense

Agent identity & access (NHI)

An agent is a non-human identity that acts with real authority, so its permissions are its blast radius - this page treats each agent as a managed, least-privilege identity across the whole provision-to-deprovision lifecycle.

Concretely, that non-human identity (NHI) holds tokens, calls APIs, touches data, and triggers actions. OWASP puts it bluntly: an AI agent is an execution principal, closer to a privileged workload than a conversational interface. NHIs already vastly outnumber human identities and are the least-governed credentials in most estates; agents make it acute because they are numerous, dynamic, and act autonomously on untrusted input. The OWASP Agentic Top 10 (III.4 · Persistence & propagation) cross-maps directly to the OWASP Top 10 for Non-Human Identities - over-privileged NHIs, secret exposure, long-lived credentials, and reused identities are the root causes that turn agent risks into incidents.

Mint an audience-bound, short-lived agent token (RFC 8707) and revoke on anomaly
# 1. OAuth token request with resource indicator (RFC 8707) -> aud-bound, 5-min TTL
curl -s -X POST https://<idp>/oauth2/token \
-d grant_type=client_credentials \
-d client_id=<agent-client-id> \
-d client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer \
-d client_assertion=<signed-jwt> \
-d resource=https://crm.internal/api \ # RFC 8707: binds the aud claim
-d scope=records:read
# -> access_token has aud=https://crm.internal/api, exp=+300s; unusable at any other API
# 2. On anomaly: revoke the identity, do NOT restart the host (III.3 containment)
curl -s -X POST https://<idp>/oauth2/revoke -d token=<access_token> \
-d token_type_hint=access_token
# and disable the workload identity so re-issue is blocked, e.g.:
aws iam update-assume-role-policy --role-name agent-<id> --policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Principal":"*","Action":"sts:AssumeRole"}]}'
aws iam delete-role-policy --role-name agent-<id> --policy-name standing-access
What the decoded agent token should look like
{ "sub": "agent://crm-summarizer", "aud": "https://crm.internal/api",
"scope": "records:read", "act": { "sub": "user:alice" },
"exp": 1751371500, "iat": 1751371200 }
flowchart TB
  PROV["Provision: per-agent NHI<br/>not a shared / static key"] --> AUTH["Authenticate<br/>mTLS + OIDC /<br/>workload identity"]
  AUTH --> AUTHZ["Authorize: least-privilege,<br/>task-scoped +<br/>on-behalf-of user"]
  AUTHZ --> ACT["Act + audit every action"]
  ACT --> DEPROV["Rotate and de-provision<br/>kill orphaned identities"]
  DEPROV -.->|"no standing super-credentials"| PROV
  classDef d fill:#0f1a18,stroke:#5bd1c5,color:#bdeee2;
  class PROV,AUTH,AUTHZ,ACT,DEPROV d;

On-behalf-of is the control everything hinges on: when an agent acts for a user it should borrow the user’s scoped authority, not wield its own standing super-credentials - so an injection can’t reach everything the agent could ever touch.

Core controls

  • One identity per agent. Never a shared human’s credentials or a static, broadly-scoped API key. Isolate agent identities from user identities.
  • Authenticate strongly. mTLS + OIDC / workload identity; for A2A (agent-to-agent), signed and verified Agent Cards (IV.4 · Multi-agent systems, A2A & the seams).
  • Authorize least-privilege, task-scoped. The agent’s permissions are its blast radius (ASI03, from the OWASP Agentic Security Initiative); deny dangerous tool combinations (IV.2 · Model Context Protocol (MCP) - MCP, the Model Context Protocol - capability chaining).
  • On-behalf-of, not super-creds. When acting for a user, use the user’s delegated, scoped authority - the single most effective limit on injection impact.
  • Short-lived, JIT credentials. No long-lived static keys; audience-bound tokens (RFC 8707); secrets in a manager, never in prompts or memory (secrets + memory poisoning = ASI06).
  • Non-transitive delegation. Authority must not accumulate across A2A hops (IV.4 · Multi-agent systems, A2A & the seams); re-scope at each boundary.
  • Lifecycle & de-provisioning. Orphaned NHIs and identity sprawl are where forgotten identities become breach vectors (V.2 · Cloud security & red-teaming) - decommission aggressively.

The mechanisms, named

The controls above are principles; these are the concrete standards that implement them. The page name-drops “audience-bound” and “on-behalf-of” - here is what actually enforces each.

Control principleMechanism (standard)What it does
Stop bearer-token theftDPoP (RFC 9449) or mTLS-bound tokens (RFC 8705)Sender-constrains the token: binds it to a client-held key (DPoP proof JWT) or client cert (cnf.x5t#S256). A stolen token is useless without the private key - the direct answer to Salesloft/Drift.
On-behalf-of / delegationToken exchange (RFC 8693)Trades a subject token for a downstream one, stamping the act (actor) claim so the delegation chain is explicit and re-scopeable at each hop.
Bootstrap workload identitySPIFFE / SPIRE, issuing SVIDsPlatform attestation (node + workload) mints a short-lived X.509 or JWT SVID with no injected “secret zero” - solves the bootstrap credential the injection would otherwise steal.
Enforce aud/scope at runtimePolicy-as-code: OPA (Rego) or CedarThe resource server checks aud, scope, and delegation against a policy at the point of use - not just at token mint. This is where least-privilege is actually decided.
Gate high-impact actionsStep-up auth / human-in-the-loopA fresh, stronger factor (or explicit human approval) before a destructive or irreversible action - so a hijacked session can’t silently escalate.

Verify the controls

Preaching least-privilege NHIs is cheap; proving your estate has none of the anti-patterns this page names is the work. Run the failure-hunt, then walk the checklist.

Find the NHI failures this page names (AWS example)
# long-lived static keys (>90d) - the anti-pattern (cutoff computed, not hardcoded)
CUTOFF=$(date -u -d '90 days ago' +%Y-%m-%d)
aws iam list-users --query 'Users[].UserName' --output text | \
xargs -I{} aws iam list-access-keys --user-name {} \
--query "AccessKeyMetadata[?CreateDate<=\`$CUTOFF\`].[UserName,AccessKeyId,CreateDate]"
# orphaned / unused roles (no activity 90d) via Access Advisor or:
aws iam generate-service-last-accessed-details --arn <role-arn>
# wildcard / admin NHI policies - resolve each policy's DEFAULT version, don't assume v1
aws iam list-policies --scope Local --query 'Policies[].Arn' --output text | \
xargs -I{} sh -c 'V=$(aws iam get-policy --policy-arn {} --query Policy.DefaultVersionId --output text); \
aws iam get-policy-version --policy-arn {} --version-id "$V" \
--query "PolicyVersion.Document" | grep -q "\"\\*\"" && echo {}'
  • One identity per agent - no shared human creds, no static broad-scoped keys.
  • Strong auth: mTLS + OIDC / workload identity; signed Agent Cards for A2A.
  • Least-privilege, task-scoped entitlements; no wildcard / admin NHI policies.
  • On-behalf-of for user actions; short-TTL, audience-bound tokens (RFC 8707).
  • Sender-constrained tokens (DPoP RFC 9449 or mTLS-bound RFC 8705) for sensitive downstream APIs.
  • Delegation via token exchange (RFC 8693) with an explicit act claim; aud/scope enforced at the resource by policy (OPA / Cedar).
  • Revocation is real: introspection (RFC 7662) or workload-identity disable, not TTL alone; step-up / human approval on high-impact actions.
  • No orphaned or unused NHIs; rotate and de-provision aggressively.

The on-behalf-of exchange, made concrete. On-behalf-of is named as the control everything hinges on above, in the mermaid, and in the checklist - but the call that actually stamps the act chain is the one artifact left theoretical. This is it: RFC 8693 token exchange trades the user’s token plus the agent’s NHI for a single scoped, audience-bound token carrying act.

Mint the on-behalf-of token: RFC 8693 exchange that stamps the act chain (produces the decoded token above)
# The agent presents the user's token (subject) + its own NHI (actor) to borrow the user's scoped authority
curl -s -X POST https://<idp>/oauth2/token \
-d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
-d subject_token=<user-access-token> \ # on whose behalf: user:alice
-d subject_token_type=urn:ietf:params:oauth:token-type:access_token \
-d actor_token=<agent-nhi-token> \ # who acts: agent://crm-summarizer
-d actor_token_type=urn:ietf:params:oauth:token-type:access_token \
-d resource=https://crm.internal/api \ # RFC 8707: binds aud, same target as the client-creds token above
-d scope=records:read
# -> returns a NEW short-lived token stamped with the act (actor) claim (the decoded token above):
# the call to crm.internal is bound to user:alice, aud=https://crm.internal/api, scope=records:read, exp=+300s.
# SCOPED: the agent borrows only records:read for this user - not its own standing rights.
# AUDITABLE: the act chain makes every crm.internal action attributable to user:alice, not to a faceless NHI.

Re-scope this token at each hop - never blindly forward it - so authority does not accumulate across delegation boundaries (IV.4 · Multi-agent systems, A2A & the seams).