AI agents that call Vault today mostly authenticate the same way any other service does. They use a long-lived AppRole secret, a Kubernetes service account token, or a shared machine-user credential baked into a container image. None of that distinguishes "the agent acting on its own" from "the agent acting because a human asked it to."
Vault already had a way to fix the first half of that problem. Its traditional JWT auth method, paired with delegation from the identity provider's own on-behalf-of grant, gives an agent a distinct identity today. It has one gap. Nothing in that method puts a hard, persistent ceiling on how far a compromised or misdirected agent can reach.
IBM's Agent Registry, new in Vault Enterprise 2.0.3+, closes that gap. You register an agent's identity once. You cap its maximum reach with a ceiling policy that does not move. Per-request delegation, not a shared credential, determines what it can actually touch.
How Vault already supports this
Agent Registry is not the first way to give an AI agent a distinct, delegated identity in Vault. The established one is Vault's traditional JWT auth method (POST /auth/jwt/login), paired with delegation performed entirely by the identity provider through its own OAuth 2.0 On-Behalf-Of grant (RFC 7523, urn:ietf:params:oauth:grant-type:jwt-bearer). HashiCorp documents this combination as a validated pattern for AI agent identity, and it predates Agent Registry entirely.
POST /oauth2/v2.0/token
grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
assertion=<user's access token>
requested_token_use=on_behalf_of
client_id=<agent's client_id>
The identity provider does the delegation work, not Vault. The returned JWT carries a chained identity in its own claim shape. azp (authorized party) is the agent's own client ID. groups and preferred_username carry the delegating user's identity and group membership straight through.
{ "azp": "products-agent-client-id", "groups": ["staging-readers"], "preferred_username": "[email protected]" }
On the Vault side, a JWT auth role binds bound_audiences to the agent's client ID and bound_claims on azp, so only that specific agent can use the role at all.
resource "vault_jwt_auth_backend_role" "agent" {
backend = vault_jwt_auth_backend.entra.path
role_name = "products-agent"
bound_audiences = [entra_agent_client_id]
bound_claims = { azp = entra_agent_client_id }
user_claim = "preferred_username"
groups_claim = "groups"
role_type = "jwt"
}
The groups claim maps to Vault external groups through vault_identity_group_alias, and those groups carry ordinary Vault policies. Login returns a real, short-lived Vault token scoped to whatever policy the user's group maps to. entity_id and preferred_username in the audit log trace back to the user. azp traces back to the agent. Both survive in the same login event.
Azure Entra ID already supports the RFC 7523 grant this method needs, which matters later in this post. AD FS's on-behalf-of endpoint uses the same grant type, but its flow doesn't keep the subject and the agent distinct in the returned token, confirmed against a real AD FS instance in the table below, so it can't deliver the same split. Cognito, Google Cloud Identity Platform, and Vault's own OIDC provider have no native path to this grant at all, confirmed directly against their own token-endpoint behavior. Keycloak is the interesting one. It shipped a same-named RFC 7523 feature, "JWT Authorization Grant," as of version 26.6, so I built it against a real Keycloak 26.7.0 instance to check. The issued token turned out to carry the pre-linked local user's own identity, not the external caller's, with no claim distinguishing the two, so it's a federated-login substitute rather than this pattern's delegation shape. I haven't built and live-tested the Entra ID or AD FS paths the way I tested Agent Registry below and Keycloak here, so treat those two as HashiCorp's and Microsoft's documented mechanics, not something I've independently reproduced end to end.
This method has a real gap, and it is not just a group-administration problem. Vault's ACL engine unions capabilities across every policy attached to a token. A path that any attached policy allows stays allowed, unless another attached policy explicitly denies that exact path at equal or greater specificity. There is no built-in way to attach a static cap and a dynamic, group-derived policy to the same token and get the intersection. Attach both, and you get their union. If a delegating user's group later gets a broader policy, the agent's effective reach grows with it silently, no matter how the static policy was written. Agent Registry exists specifically to add the missing operation, a second, independent evaluation pass that intersects with the baseline instead of adding to it.
The RFC underneath it
Agent Registry is not a bespoke HashiCorp protocol either. It is built on two IETF specs that already exist independently of Vault, and independently of the JWT-auth method above.
RFC 8693: OAuth 2.0 Token Exchange defines a grant type, urn:ietf:params:oauth:grant-type:token-exchange, for trading one token for another at an authorization server's token endpoint.
| Param | Required | Meaning |
|---|---|---|
subject_token | yes | The token being exchanged, whose identity the new token represents |
subject_token_type | yes | URN identifying subject_token's format (...token-type:access_token, :jwt, etc.) |
actor_token | no | Present only for delegation, identifying the party acting on behalf of the subject |
actor_token_type | if actor_token present | URN for actor_token's format |
requested_token_type | no | URN for the token type the caller wants back |
When actor_token is present, RFC 8693 §4.1 says the issued token SHOULD carry a nested act claim chaining actor identity to subject identity.
{ "sub": "end-user", "act": { "sub": "calling-agent" } }
That is the entire shape Vault's Agent Registry keys on. A plain client_credentials grant never produces an act claim. Only the token-exchange grant, with an actor token in the request, does.
RFC 9396: OAuth 2.0 Rich Authorization Requests (RAR) is the second piece, and it solves a different problem than the ceiling does. The ceiling, keyed off RFC 8693's act claim, sets an agent's persistent maximum reach across every request it ever makes. RAR narrows a single request further, below whatever the ceiling already allows, when one specific task needs a tighter, temporary scope. It defines an authorization_details JSON array a client can request and an authorization server can embed in the issued token. It scopes exactly what that one token is good for, more finely grained than an OAuth scope string. Vault's Agent Registry can require this claim per-agent (optional_authorization_details = false). It evaluates the claim as a third, per-request narrowing layer on top of policy.
Neither RFC mentions AI agents. Vault's contribution is wiring act.sub resolution and authorization_details evaluation into its own ACL engine. It also gives admins a registry to manage which entities are allowed to show up as an act.sub in the first place.
The three layers, as code
Vault evaluates a delegated (on-behalf-of) request against three things. All three must agree.
Bearer JWT arrives at any Vault API path
│
▼
┌─ Baseline ACL ────────┐
│ Does the SUBJECT's │── NO ──> DENIED
│ entity policy allow │
│ this path? │
└────── YES ─────────────┘
│
▼
┌─ Ceiling policy ──────┐
│ Does the AGENT's │── NO ──> DENIED
│ Agent Registry ceiling│
│ permit this path? │
└────── YES ─────────────┘
│
▼
┌─ RAR (if required) ───┐
│ Does authorization_ │── NO ──> DENIED
│ details in the JWT │
│ allow this operation? │
└────── YES ─────────────┘
│
▼
ALLOWED
Here is the part that is easy to miss. The baseline ACL that matters is the subject's, not the agent's. For a delegated request, Vault resolves the top-level sub claim to the subject's identity entity. It evaluates that entity's ordinary policy, the same policy a human or service would have. It only uses act.sub to look up the agent's Agent Registry record and pull its ceiling policies. The agent's own baseline policy is never consulted during a delegated request. It only matters when the agent authenticates as itself, with no delegation at all.
Final access is the overlap of those two sets. Only paths that appear in both the subject's baseline ACL and the agent's ceiling policy are allowed. If the subject can reach staging and prod, but the agent's ceiling caps it to staging, final access is staging only. Prod is in the subject's baseline, but outside the ceiling, so it is removed.
A ceiling policy never grants anything. It only removes paths the subject's baseline would otherwise allow. An agent with an empty ceiling (no policies, and no_default_ceiling_policy = true) gets nothing, even when delegated by an admin. An empty overlap with anything is still empty.
Here is a ceiling policy that caps an agent to staging, no matter who delegates to it.
path "secret/data/staging/*" {
capabilities = ["read", "list"]
}
path "agent-registry/registration/entity-id/{{identity.entity.id}}" {
capabilities = ["read"]
}
Write it with vault policy write:
vault policy write agent-staging-ceiling - <<EOF
path "secret/data/staging/*" {
capabilities = ["read", "list"]
}
path "agent-registry/registration/entity-id/{{identity.entity.id}}" {
capabilities = ["read"]
}
EOF
In practice, this was the single most useful property of the whole feature. I delegated the same agent by a low-privilege subject and a high-privilege subject. Prod access stayed denied in both cases because the ceiling capped it. On an otherwise identical agent with a permissive ceiling, the high-privilege subject's prod access came straight through. Same agent, same request shape, only the ceiling differed.
Implementing it
Getting from zero to a working delegated request takes five steps.
1. Activate the OAuth Resource Server. This is a feature flag. It is not on by default.
vault write -f sys/activation-flags/oauth-resource-server/activate
2. Point Vault at your identity provider by writing an OAuth Resource Server profile with its issuer and JWKS endpoint.
vault write sys/config/oauth-resource-server/my-idp \
issuer_id="https://my-idp.example.com" \
jwks_uri="https://my-idp.example.com/oauth/v2/keys"
This is a genuinely different front door than Vault's traditional JWT auth method (POST /auth/jwt/login). No /auth/login call happens. Vault never issues a Vault token or lease. The JWT itself is the credential, presented as a Bearer token directly to any Vault API path.
3. Create an identity entity and alias for the agent. Vault can then resolve the act.sub value in an incoming JWT to something with a ceiling attached.
AGENT_ENTITY_ID=$(vault write -field=id identity/entity name=billing-agent)
vault write identity/entity-alias \
name="<agent's numeric sub from the IdP>" \
canonical_id="$AGENT_ENTITY_ID" \
issuer="https://my-idp.example.com" \
external_id="<agent's numeric sub from the IdP>"
An OAuth Resource Server alias keys off issuer. It does not use a traditional auth
method's mount_accessor. Vault matches issuer against the JWT's iss claim. This
match tells Vault which entity the token belongs to.
external_id is also required. Omit it, and the alias write still succeeds with no
warning. But a delegated request that presents this identity as act.sub then fails.
Vault returns no alias found and error looking up actor entity.
Subjects (the humans or systems an agent might act for) need the same entity and alias treatment. Vault can then resolve the top-level sub claim. They never get a registry record. Only agents do.
4. Register the agent with POST /agent-registry/register.
curl --header "X-Vault-Token: $VAULT_TOKEN" --request POST \
--data '{
"display_name": "billing-agent",
"entity_id": "'"$AGENT_ENTITY_ID"'",
"ceiling_policies": ["agent-staging-ceiling"],
"no_default_ceiling_policy": false,
"optional_authorization_details": true
}' \
http://127.0.0.1:8200/v1/agent-registry/register
An entity must have a registry record before Vault's OAuth Resource Server will accept it as an agent at all. Unless no_default_ceiling_policy is true, Vault automatically adds default and default-ceiling to the list. The full CRUD surface follows the same pattern as most Vault secret backends, with read/update/delete/list by id, by display_name, and read-only by entity_id.
| Operation | Path |
|---|---|
| Create/update | POST /agent-registry/register |
| Read/update/delete by ID | GET|POST|DELETE /agent-registry/registration/id/:id |
| Read/update/delete by name | GET|POST|DELETE /agent-registry/registration/display-name/:display_name |
| Read by entity | GET /agent-registry/registration/entity-id/:entity_id |
| List | LIST /agent-registry/registration/id or /registration/display-name |
5. Get a genuine delegated JWT and use it. This part depends on your identity provider correctly implementing RFC 8693 delegation. It is not enough to support the token-exchange grant. The provider must support the actor_token parameter specifically. It is a three-round-trip exchange.
# 1. Token for the subject being acted for
SUBJECT_TOKEN=$(curl -s -d grant_type=client_credentials \
-d client_id=$SUBJECT_ID -d client_secret=$SUBJECT_SECRET \
$TOKEN_ENDPOINT | jq -r .access_token)
# 2. Token for the agent (the actor)
ACTOR_TOKEN=$(curl -s -d grant_type=client_credentials \
-d client_id=$AGENT_ID -d client_secret=$AGENT_SECRET \
$TOKEN_ENDPOINT | jq -r .access_token)
# 3. Exchange, presenting both
JWT=$(curl -s \
-d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
-d subject_token=$SUBJECT_TOKEN -d subject_token_type=urn:ietf:params:oauth:token-type:access_token \
-d actor_token=$ACTOR_TOKEN -d actor_token_type=urn:ietf:params:oauth:token-type:access_token \
-d requested_token_type=urn:ietf:params:oauth:token-type:jwt \
$TOKEN_ENDPOINT | jq -r .access_token)
# 4. Use it directly, no /auth/login involved
curl --header "Authorization: Bearer $JWT" \
http://127.0.0.1:8200/v1/secret/data/staging/db-creds
The resulting JWT has top-level sub equal to the subject and act.sub equal to the agent. Vault resolves both via their entity aliases. It evaluates the subject's baseline, caps it with the agent's ceiling, and checks authorization_details if the registration requires it.
A bug worth knowing about
While working through this, delegated requests kept failing with a bare 403. No entity resolved in the audit log, at every log verbosity. That included a completely genuine, identity-provider-issued act claim. I had ruled out a programmatically injected claim as the cause. The only trace at trace logging was a single line confirming Vault did resolve the actor entity. Then nothing further before the deny.
The cause turned out to be the ceiling policy itself. It had an explicit capabilities = ["deny"] block for the paths it was meant to keep off-limits. Those blocks sat on top of allow rules for the paths it should grant. Removing the explicit deny block fixed it completely. I left the ceiling allow-only, with everything else blocked purely by omission. Delegated requests immediately started populating a full auth block in the audit log (entity_id, identity_policies, policy_results), exactly as documented.
# BROKEN: an explicit deny inside a ceiling policy silently breaks
# on-behalf-of entity resolution. No error and no log line at any verbosity.
path "secret/data/prod/*" {
capabilities = ["deny"]
}
# FIXED: block by omission instead. Only list what the ceiling allows.
Do not write explicit deny blocks into ceiling policies. Ordinary baseline ACL policies handle explicit denies fine. This only showed up on the ceiling specifically. Verify this against your own Vault Enterprise build before you rely on it. Versioned unified-docs content under vault/v2.x is a signal this feature is still stabilizing.
Which identity providers can actually do this
Registering an agent and writing a ceiling policy is the easy half. The hard half is that Vault only sees genuine delegation if your identity provider actually implements RFC 8693's actor_token parameter and emits a real act claim. It is not enough to support the token-exchange grant type in isolation.
The first live check is discovery metadata. Every OIDC provider publishes <issuer>/.well-known/openid-configuration. Look for the token-exchange grant in grant_types_supported.
curl -s "<issuer>/.well-known/openid-configuration" | jq '.grant_types_supported'
You want urn:ietf:params:oauth:grant-type:token-exchange in that array. Some authorization servers also publish the same field at /.well-known/oauth-authorization-server (RFC 8414). Presence is a useful positive signal. Absence is not a final "no." A few providers support a grant without advertising it, or only behind a flag. Discovery still will not tell you the part Vault actually needs. There is no standard metadata field for actor_token or an act claim. That always requires vendor docs, or minting an exchanged token and decoding it.
I researched the providers below against exactly that bar. This table is the ceiling and delegation half only. Treat "programmatic path" and "live ceiling result" as one axis. RAR gets its own table further down, since a provider can land differently on each.
| Provider | Real actor_token → act claim delegation? | Programmatic path to act.sub? | Live ceiling result against Vault |
|---|---|---|---|
| Keycloak | ❌ No. Standard Token Exchange V2 has no act. A separate experimental "Token Exchange Delegation" emits may_act (pre-authorization), a different claim entirely. Real RFC 8693 actor_token → act support is tracked in keycloak/keycloak#38279 (open, milestoned for 26.8.0 as preview at best) | ✅ Yes, via a Hardcoded Claim protocol mapper, JSON type | ❌ Blocked. Vault rejects every Keycloak token with unsupported jwt type: Bearer, unrelated to the shim itself |
| Amazon Cognito | ❌ No. No token-exchange grant exists in User Pools at all | ✅ Yes, via a Pre Token Generation Lambda (event version V3_0 for machine tokens) | ✅ Full success. Baseline and ceiling both enforced correctly |
| Azure Entra ID | ❌ No. OBO uses RFC 7523 jwt-bearer, not RFC 8693. No act claim in the token schema | ❌ No. Every claim-customization surface explicitly disallows object-valued claims, and act/actor are reserved names | ❌ No path. Confirmed live with byte-identical 403 whether shimmed or not |
| Google Cloud Identity (Workload Identity Federation / STS) | ❌ No. The STS API schema has no actorToken field and cannot emit a JWT to carry act | ⚠️ Yes, but only via the separate Identity Platform surface, not Workload Identity Federation | ✅ Full success. Baseline and ceiling both enforced correctly |
| Active Directory (AD FS) | ❌ No. Pre-RFC-8693 draft OBO (jwt-bearer + requested_token_use=on_behalf_of) | ✅ Yes, via a custom claim rule with ValueType="...#json" | ⚠️ Claim shape confirmed correct, but AD FS's OBO flow never preserves a distinct subject identity, so there is no subject/agent split left for a ceiling to narrow |
Vault's own OIDC provider (identity/oidc/provider) | ❌ No. grant_types_supported is ["authorization_code"] only. token-exchange, jwt-bearer, and client_credentials all return 400 unsupported_grant_type, confirmed live | ❌ No, no claims-customization surface at all (claims_supported is empty) | ❌ No path. It is Vault's own issuer, not an external identity provider, and has no delegation grant to test |
None of these has genuine RFC 8693 actor-delegation out of the box. Everyone either has no token-exchange grant at all, or only does subject-token exchange without a delegation chain. Entra ID is the one hard no straight down the row. There is no mechanism, native or programmatic, for it to emit an object-valued claim at all.
Keycloak is the near-miss that still ends blocked in practice. Native RFC 8693 actor_token → act delegation is the open request in keycloak/keycloak#38279, milestoned for 26.8.0 as preview at best. The Hardcoded Claim mapper can produce the right claim shape on a plain client_credentials token. In my run Standard Token Exchange V2 did not apply that mapper type. That contradicts a maintainer comment on keycloak/keycloak#30358. Separately, Vault rejects every Keycloak token I presented with unsupported jwt type: Bearer because of a hardcoded payload typ claim. I have not found a public issue that tracks that exact gap.
RAR, checked separately from the ceiling
Do not read a provider's ceiling result and assume RAR (authorization_details) landed the same way. It does not always. RAR is a separate claim, evaluated as a separate, later layer. A provider can land differently on it than it did on act.sub. I ran isolated tests for this specifically. I toggled one shim independently of the other. A "yes" here means RAR was proven to work or fail on its own, not just inferred from the ceiling result.
| Provider | Native RAR support? | Programmatic path to authorization_details? | Live RAR result against Vault |
|---|---|---|---|
| Amazon Cognito | ❌ No | ✅ Yes, the same Lambda adds the array | ✅ Full success, isolated from the ceiling test. A request within the ceiling still got narrowed to 403 RAR_NO_MATCH when authorization_details did not cover it |
| Google Cloud Identity | ❌ No | ✅ Yes, custom claims accept the array well under the size cap | ✅ Full success, isolated the same way |
| Keycloak | ❌ No (real support is OID4VCI-only, and still experimental) | ✅ Yes, confirmed correct at the source level. A Hardcoded Claim mapper with JSON type produces a genuine ArrayNode, not a string | ❌ Never reached. Blocked by the same typ rejection as the ceiling test, not a RAR-specific failure |
| Active Directory (AD FS) | ❌ No | ✅ Yes, confirmed live with a genuine top-level authorization_details array | ⚠️ The claim shape works and Vault parses it, but it cannot be paired with a full ceiling-narrowing demo, for the same subject/agent-split reason as the row above |
| Azure Entra ID | ❌ No | ❌ No, authorization_details sits on Entra's reserved claim list too | ❌ No path, confirmed live |
| Vault's own OIDC provider | ❌ No | ❌ No, confirmed live. A real authorization_details array attached to /authorize is accepted but never threaded into the resulting id_token | ❌ No path, confirmed live |
Keycloak and AD FS are the two rows worth sitting with. Keycloak's shim is provably correct, but an unrelated bug blocks it before it ever gets a chance to matter. AD FS's shim is the opposite of its own ceiling result. RAR works standalone even though the ceiling never got a usable subject/agent split to narrow in the first place. "RAR is negative" and "ceiling is negative" turned out to be two different questions with two different answers, provider by provider, not one verdict that covers both.
Why bother with any of this
The honest case for Agent Registry is not "AI agents need secrets." Every secrets manager already does that, including Vault's own JWT auth method described at the top of this post. Agents specifically break the assumption every prior access model was built on. The thing making the request was either a human who can be asked "are you sure," or a service whose call graph was written by a human and does not change at runtime. An agent can decide, based on a prompt, to call an API it has never called before, on behalf of a user it is impersonating, in a way nobody explicitly coded.
A shared service-account credential cannot tell you who actually authorized the action. With OBO delegation, whether through the established JWT-auth method or through Agent Registry, the audit log carries both identities in the same event. That is real attribution, not "some process using the shared prod key did something at 2am."
"What's the worst this agent could ever do" needs to be answerable independent of what the agent is asked to do, and independent of what the delegating user's own permissions happen to be today. That is the specific gap between the two methods in this post. The JWT-auth path answers "who authorized this" correctly, but the agent's effective reach still moves with whatever policy the delegating user's group carries. Agent Registry's ceiling is a static, admin-set boundary that does not move no matter which subject delegates to the agent or what the agent's own logic decides to attempt. Same agent, same request shape, only the ceiling differed, produced different results in testing above precisely because the ceiling does not care who is asking.
Where this leaves the two methods
Static, long-lived credentials are a liability specifically because agents run unattended, and prompt injection or tool misuse can manipulate them in a way a human operator would resist. Both methods in this post remove that liability the same way, by having the identity provider issue short-lived, delegation-scoped JWTs instead. They differ on the second axis, how tightly the agent's ceiling is pinned once delegation happens.
If your identity provider already supports RFC 7523 OBO with a distinct subject and agent claim, Entra ID today, the JWT-auth method at the top of this post is available now. It is built on primitives Vault has shipped for years, and it gets you distinct agent identity plus a two-identity audit trail today. AD FS speaks the same grant type, but its OBO flow does not preserve that same split, so it does not qualify yet. Adopt the JWT-auth method first if attribution is the immediate problem and your provider actually supports it.
Layer Agent Registry on top once your identity provider closes the RFC 8693 gap. None of the six providers in the tables above support real actor_token delegation natively yet, though Keycloak has an open tracking issue and Cognito or Google Cloud Identity can get there today through a programmatic shim. ZITADEL, outside that table, already implements RFC 8693 actor-delegation natively, and was the identity provider I got fully working end to end, with a genuine identity-provider-issued act claim, doing exactly what the docs describe. Until your own identity provider gets there, Agent Registry's ceiling and RAR stay reachable in a lab, not through your production identity plane. That is a timeline problem for your provider's roadmap, not a reason to treat the mechanism as unsound.