Secure agent permissions with Vault
Challenge
AI agents have become a standard part of the software lifecycle.
Common challenges include:
Agents clone branches, run lint tools, summarize diffs, and post review comments.
Agent work requires credentials to access repositories and services.
Teams write long-lived access tokens into environment files and pass them to agents.
Tokens often grant more access than required for a single session.
Tokens may never expire on their own.
Token usage is difficult or impractical to audit.
Persistent access tokens pose privilege escalation risks through token capture and misuse.
Solution
Vault Enterprise and HCP Vault Dedicated enables an agent to authenticate with an OAuth access token that carries a Rich Authorization Request (RAR) claim. The tutorial uses a coding agent example, but the solution works for any AI agent.
This least-privilege approach implements zero trust principles by ensuring agent credentials are ephemeral and narrowly scoped through delegation. The agent acts on behalf of a human developer and receives an effective permission set that is the intersection of three independent controls:
Human baseline ACL policies define what the person can do
OBO ceiling policies from the Agent Registry in Vault cap the maximum the agent can ever do
Per-session RAR claims carried in the token specify what one session may access
Benefits of this approach include:
Agents can do some of what the human can do, but never all or more
Agent capabilities are ephemeral and narrow-scoped for each request
Every agent action is auditable and attributable to both the human and agent
Token lifetime drops from days or weeks to minutes
Eliminates persistent credential exposure risks
Why this matters
For decision-makers: This pattern reduces security risk and operational overhead:
Reduces token lifetime from days/weeks to minutes, minimizing exposure window
Eliminates persistent credentials that attackers can capture and misuse
Provides per-session audit trails for compliance and incident response
Enforces least privilege automatically through policy intersection
Scales to any agent type, such as CI/CD, monitoring, and automation tools
For implementers: This workflow provides:
Fine-grained control over agent permissions without managing multiple credential sets
Flexible delegation that adapts to different use cases and security requirements
Clear attribution of every agent action to both human and agent identities
Production-ready OAuth 2.0 integration with enterprise identity providers
Personas
The tutorial follows two personas with distinct responsibilities. You can assume their roles to complete the lab portion of the tutorial.
Oliver the Vault operator: Enables Vault features, configures Auth0 trust through an OAuth resource server profile, creates the identity entities and aliases, registers the agent with OBO ceiling policies, and enables auditing.
Danielle the developer and agent owner: Authenticates through Auth0, mints the delegated RAR token, runs the agent, and inspects the audit trail.
Scenario
The HashiCups team maintains a cup-printer service. Pull requests against the cup-printer service codebase pile up faster than the team can review them, and most of the backlog are lightweight tasks with few heavy lifts. Rather than have the team use their time on linting violations, missing tests, and minor policy issues, the HashiCups team is experimenting with using agents to handle some of these tasks.
Danielle is a developer on the team and wants a coding agent to do the boring first pass on every PR so that the team can focus on the hard problems in a review along with human to human interaction.
The agent needs a repository access token to clone the PR branch and post comments, and that token lives in Vault.
Danielle has broad access to the HashiCups secret tree, both the repo/* build
tokens and the prod/* production secrets. The agent must not inherit all of
this access.
The team imposes the following guardrails for the agent:
The agent may only read the repository token under
repo/*.The agent may never read anything under
prod/*, even though Danielle can.The agent may never write, even though Danielle can.
For any single review session, the agent receives a token scoped to precisely the one secret it needs and nothing else.
Oliver is the HashiCups team Vault operator, and it is their job to configure the trust between Vault and the team's identity provider. In this scenario, the team uses Auth0 as an authorization server and Oliver's jobs to be done include preparing the Auth0 account for this role.
To do so, Oliver must configure the trust between Vault and Auth0, register the agent with the Agent Registry in Vault, configure OBO ceiling policies that enforce the guardrails, and configure the authorization server.
This enables the agent acting on behalf of Danielle to acquire access tokens that capture the delegation and narrowly scoped permission that the agent can request from Vault.
Prerequisites
You need the following to complete the lab scenario in this tutorial:
A Vault version 2.2.0 or later binary installed and in your
PATH.A Vault Enterprise license.
Docker Desktop or other Docker API-compatible containerization solution installed with at least 4 GB RAM allocated and 2 GB free disk space.
An Auth0 tenant on the Enterprise plan, which Auth0 Custom Token Exchange requires. You need permissions to create applications, APIs, users, and Actions, and to use the Auth0 Management API. Note the tenant name and region, which appear in the top left of the Auth0 Dashboard and on the Settings > General page. Refer to Auth0 pricing for feature availability by plan tier.
Version 1.15.0 or later of the
oauth2ccommand line tool installed and in yourPATHfor the client credentials and browser-based authorization code workflows.curlfor the Auth0 Management API and token exchange requests.jqversion 1.6 or later for parsing API responses.The
jwtcommand line tool for decoding tokens.A coding agent such as OpenCode. The agent is only required for the final integration step. All Vault features in this tutorial work with
vaultandcurlalone.
Set up the lab
Clone the learn-vault-agentic-iam repository containing Docker Compose configuration, initialization scripts, ACL policies, and helper scripts for the lab environment.
Clone the repository.
$ git clone \ https://github.com/hashicorp-education/learn-vault-agentic-iamChange into the project directory.
$ cd learn-vault-agentic-iamExport your Vault Enterprise license to enable enterprise features in the container.
$ export VAULT_LICENSE=3894JFNCOFFEE...Start the Vault server and supporting containers in detached mode.
$ docker compose up -dWait for the initialization to complete by checking the server logs.
$ docker compose logs vault-init | grep 'Vault lab is preconfigured and ready' vault-init-1 | [bootstrap] Vault lab is preconfigured and ready.If you do not find this message, wait a moment and run the command again.
Configure your shell environment by sourcing the environment file.
$ source ./env.sh VAULT_ADDR = https://127.0.0.1:8200 VAULT_CACERT = /path/to/project/tls/vault-ca.pemVerification: Confirm
VAULT_ADDRis set tohttps://127.0.0.1:8200andVAULT_CACERTpoints to the CA certificate file.Verify Vault server is running and unsealed.
$ vault statusExpected result: Output shows
Sealed: falseandVersion: 2.2.0or later.Authenticate to Vault as the operator persona.
$ vault login -method=userpass \ username=oliverWhen prompted for a password, enter:
oliver-example-YgGk0ycnbrUOOEuxVerification: Successful login displays
Success! You are now authenticated.Verify ACL policies are loaded.
$ vault policy listExpected result: Output includes
danielle-developerandagent-repo-reviewer-ceilingpolicies.
Your lab environment is ready for you to complete the scenario steps.
How the request flow works
Before you build anything, it helps to know where each control lives.
Vault acts as an OAuth 2.0 resource server. It accepts the identity
provider's JWT directly in the X-Vault-Token header on every request,
with no separate vault login step.
The JWT acts as the Vault token in this workflow.
The following OAuth 2.0 specifications define this workflow:
- RFC 9068 - JWT Profile for OAuth 2.0 Access Tokens defines the JWT format Vault validates
- RFC 9396 - OAuth 2.0 Rich Authorization Requests (RAR) provides fine-grained authorization through detailed permission claims
- RFC 8693 - OAuth 2.0 Token Exchange defines the on-behalf-of delegation pattern with
actclaims
Agent only and agent OBO workflows
When Vault receives an on-behalf-of token, which is a JWT that contains claims
for the actor and subject. In this example, you use the act claim and Vault authorizes the request as follows.
Vault resolves the subject from the
subclaim to a Vault entity, which contributes that person's baseline policies.Vault resolves the actor, which is the agent, from the
act.subclaim to a Vault entity. That entity must have an Agent Registry record, which contributes its OBO ceiling policies. Vault ignores the actor's own baseline policies, and only the OBO ceiling policies apply for an agent.Vault allows the request only if both the subject baseline and the OBO ceiling permit it.
When the token carries an
authorization_details(RAR) claim, the request must match one of the entries listed there. If the claim is not present, Vault does not apply this narrowing and instead grants those permissions that the subject and the OBO ceiling policy have in common.
You can observe all four of these behaviors in the lab scenario.
Configure Auth0
Complete this section as Oliver, the Vault operator.
Oliver now configures Auth0 so it can issue RFC 9068 access tokens for Vault, and so users can exchange their own token and the agent's token for a single delegated on-behalf-of (OBO) token that carries a RAR claim.
Auth0 issues the delegated token through Custom Token Exchange, which implements RFC 8693. Two Auth0 Actions do the work:
A Custom Token Exchange Action verifies the subject (human) token and the actor (agent) token, sets the token subject, and adds the
actclaim.A Post Login Action injects the requested
authorization_details(RAR) into the issued access token as a custom claim.
Export your Auth0 tenant domain. Replace
<your-tenant>and the region with your tenant values.$ export APP_DOMAIN="<your-tenant>.us.auth0.com"The export command does not produce any output.
Create the API
The API represents the audience that validates the JWT Auth0 issues.
Navigate to Applications > APIs.
Click Create API.
For Name, enter
vault-api.For Identifier, enter the Vault API address:
https://localhost:8200/v1. The identifier value becomes the JWTaudclaim.For JSON Web Token (JWT) Profile, select RFC 9068.

Scroll down and click Create.
Click the Settings.
Scroll down to RBAC Settings.
Click the toggle to switch on Enable RBAC.
Click the toggle to switch on Add Permissions in the Access Token.

Click Save.
Create the user
Create a user to represent Danielle, the human subject who delegates authority to the agent.
Navigate to User Management > Users.

Click Create User and select Create via UI.
For Email, enter an email address. You can use an
@example.comaddress for this tutorial.For Password, enter a strong password with at least one number and one special character, and record it somewhere safe to use in later steps.

Click Create.
On the user page, click Edit next to Email.

Set email as verified.

Copy the user_id value from the user page, for example
auth0|c0ff33a2dc1f8e9de0dd95d7, and export it. The value contains a pipe character, so wrap it in single quotes so the shell does not interpret it as a pipe.$ export SUBJECT_USER_ID='auth0|c0ff33a2dc1f8e9de0dd95d7'
Create the agent application
The agent authenticates to Auth0 as a machine-to-machine application.
Navigate to Applications > Applications, then click Create Application.
For Name, enter
ai-agent.For application type, choose Machine to Machine Applications.
Click Create.
When prompted to authorize the application, select vault-api and click Authorize.
Click Settings.
Copy the Client ID and Client Secret values and export them.
$ export AGENT_CLIENT_ID="<agent client id>" \ AGENT_CLIENT_SECRET="<agent client secret>"Scroll to Application URIs.
In Allowed Callback URLs, enter the
oauth2ccallback URL:http://localhost:9876/callback.Click Advanced Settings.
Open the Grant Types tab and clear every checkbox except Client Credentials.
Click Save.
Create the application
The application is the client that requests tokens on behalf of the user and performs the token exchange.
Navigate to Applications > Applications, then click Create Application.

For Application name, enter
HashiCups Vault.For application type, choose Regular Web Application.
Click Create.

Click Settings.

Copy the Client ID and Client Secret values and export them.
$ export APP_CLIENT_ID="<application client id>" \ APP_CLIENT_SECRET="<application client secret>"Scroll to Application URIs.
In Allowed Callback URLs, enter the
oauth2ccallback URL:http://localhost:9876/callback.
Click Advanced Settings.
Click OAuth.
In Allowed APPs/APIs, enter the Vault API address:
https://localhost:8200/v1.
Open the Grant Types tab and select the Authorization Code checkbox.

Open the Endpoints tab and note the OAuth Authorization URL and the JSON Web Key Set URL values.

Click Save.
Export the JSON Web Key Set URL and the issuer URL. The issuer URL is your tenant domain with a trailing slash.
$ export APP_JWKS_URI="https://${APP_DOMAIN}/.well-known/jwks.json" \ APP_ISS_URL="https://${APP_DOMAIN}/"
Grant application access
With the API and both applications created, grant client access from the API to each application.
Navigate to Applications > APIs.
Click vault-api.
Click Application Access.
Next to the HashiCups Vault application, click Edit.
Click Grant Access.
Click the Always grant all permissions checkbox.

Click Save.
Repeat these steps for the ai-agent application.

Get a Management API access token
The Auth0 Management API lets you automate administrative tasks on the tenant. You need a Management API access token to enable Custom Token Exchange and to create a token exchange profile, because the Auth0 Dashboard does not expose these settings.
Navigate to Applications > APIs.
Click Auth0 Management API.
Click the Advanced tab.
Click Create & Authorize Test Application. Auth0 creates an application that can call the Management API for your tenant.
Click the API Explorer tab, then copy the token and export it.
$ export MGMT_TOKEN="<management api token>"
Enable Custom Token Exchange for the application
Use the Management API to allow the HashiCups Vault application to use custom token exchange profiles.
Update the application.
$ curl --request PATCH \ --url "https://${APP_DOMAIN}/api/v2/clients/${APP_CLIENT_ID}" \ --header "Authorization: Bearer ${MGMT_TOKEN}" \ --header 'Content-Type: application/json' \ --data '{"token_exchange": {"allow_any_profile_of_type": ["custom_authentication"]}}' \ | jq '{name, is_first_party, oidc_conformant, token_exchange}'Example output:
{ "name": "HashiCups Vault", "is_first_party": true, "oidc_conformant": true, "token_exchange": { "allow_any_profile_of_type": [ "custom_authentication" ] } }Verification: Confirm
is_first_partyandoidc_conformantare bothtrue. Token exchange fails if either value isfalse.
Create the Custom Token Exchange Action
This Action verifies the subject and actor tokens, sets Danielle as the token
subject, adds the agent as the act claim, and passes any requested
authorization_details to the Post Login Action.
Navigate to Actions > Library.
Click Create Action and select Create Custom Action.
For Name, enter
process_obo_rar_token_cte.For Trigger, select Custom Token Exchange.
For Runtime, keep Node 22.
Click Create.
Replace the editor contents with the following code. Replace
<your-tenant>.us.auth0.comwith yourAPP_DOMAINvalue, and<application client id>with yourAPP_CLIENT_IDvalue.const { createRemoteJWKSet, jwtVerify } = require('jose'); // Initialize the JWKS set outside the handler to cache public keys across warm executions const AUTH0_DOMAIN = '<your-tenant>.us.auth0.com'; const EXPECTED_ISSUER = `https://${AUTH0_DOMAIN}/`; const JWKS = createRemoteJWKSet(new URL(`https://${AUTH0_DOMAIN}/.well-known/jwks.json`)); // Applications allowed to perform the token exchange const ALLOWED_CLIENT_IDS = new Set([ '<application client id>' ]); // Validates signature, issuer, expiration, and RS256 algorithm async function verifyTenantJwt(token) { const { payload } = await jwtVerify(token, JWKS, { issuer: EXPECTED_ISSUER, algorithms: ['RS256'], clockTolerance: '10s' }); return payload; } exports.onExecuteCustomTokenExchange = async (event, api) => { // 1. Authorize the calling application if (!ALLOWED_CLIENT_IDS.has(event.client.client_id)) { return api.access.deny('invalid_client', 'Client is not authorized for token exchange.'); } // 2. Extract the inbound tokens const subjectToken = event.transaction?.subject_token; const actorToken = event.transaction?.actor_token || event.request.body?.actor_token; if (!subjectToken) { return api.access.rejectInvalidSubjectToken('Missing required subject_token.'); } // 3. Verify the subject token and set the subject user let subjectPayload; try { subjectPayload = await verifyTenantJwt(subjectToken); } catch (err) { return api.access.rejectInvalidSubjectToken( `Subject token verification failed: ${err.message}` ); } if (!subjectPayload.sub) { return api.access.rejectInvalidSubjectToken('Subject token does not contain a "sub" claim.'); } api.authentication.setUserById(subjectPayload.sub); // 4. Verify the actor token and add the RFC 8693 act claim if (actorToken) { let actorPayload; try { actorPayload = await verifyTenantJwt(actorToken); } catch (err) { return api.access.deny('invalid_request', `Actor token verification failed: ${err.message}`); } if (actorPayload.sub) { api.authentication.setActor({ sub: actorPayload.sub }); } } // 5. Pass the requested RAR to the Post Login Action through app_metadata const rawRar = event.request.body?.authorization_details; if (rawRar) { let parsedRar = rawRar; if (typeof rawRar === 'string') { try { parsedRar = JSON.parse(rawRar); } catch { return api.access.deny('invalid_request', 'Invalid JSON in authorization_details.'); } } api.user.setAppMetadata('transient_rar_details', parsedRar); } };Click Deploy.
Copy the Action ID from the end of the browser address bar, for example
https://manage.auth0.com/dashboard/us/<your-tenant>/actions/library/details/90f84643-f983-43f5-93a8-c0ff33dd7f4, and export it.$ export CTE_ACTION_ID="90f84643-f983-43f5-93a8-c0ff33dd7f4"
Review the Auth0 Actions coding guidelines before you adapt this code for production use.
Create the token exchange profile
A token exchange profile links a custom subject token type to the Action that
handles it. When a token exchange request uses the
urn:vault:params:oauth:token-type:access_token subject token type, Auth0 runs
the process_obo_rar_token_cte Action.
Create the profile.
$ curl --request POST \ --url "https://${APP_DOMAIN}/api/v2/token-exchange-profiles" \ --header "Authorization: Bearer ${MGMT_TOKEN}" \ --header 'Content-Type: application/json' \ --data '{ "name": "vault-obo-rar", "subject_token_type": "urn:vault:params:oauth:token-type:access_token", "action_id": "'"${CTE_ACTION_ID}"'", "type": "custom_authentication" }'
Refer to the Auth0 create a custom token exchange profile documentation to learn more.
Create the Post Login Action
This Action reads the RAR details that the Custom Token Exchange Action stored, adds them to the access token as a custom claim, and then removes them from the user metadata.
Navigate to Actions > Library.
Click Create Action and select Create Custom Action.
For Name, enter
inject_rar_claims_post_login.For Trigger, select Login / Post Login.
For Runtime, keep Node 22.
Click Create.
Replace the editor contents with the following code.
exports.onExecutePostLogin = async (event, api) => { const rarDetails = event.user.app_metadata?.transient_rar_details; const rarClaimNamespace = 'https://vault.hashicups.com'; if (rarDetails) { // Inject the RAR as a namespaced custom claim api.accessToken.setCustomClaim(`${rarClaimNamespace}/authorization_details`, rarDetails); // Clean up the transient user metadata api.user.setAppMetadata('transient_rar_details', null); } };Click Deploy.
Navigate to Actions > Library and confirm both
process_obo_rar_token_cteandinject_rar_claims_post_loginappear in the list.
Add the Post Login Action to the trigger
Navigate to Actions > Triggers.
Click post-login.
Drag the
inject_rar_claims_post_loginAction from the Add Action pane to a position between Start and Complete.Click Apply.
The Auth0 configuration is complete. Auth0 can now issue a token for the agent,
a token for Danielle, and exchange both for a delegated token that carries the
act claim and the requested RAR.
Create the OAuth resource server profile
Complete this section as Oliver, the Vault operator.
The profile is the trust anchor. It tells Vault which issuer to trust, where to fetch signing keys, which audience to require, which algorithms to accept, and which claim carries the RAR.
Create the profile definition using JWKS mode to fetch signing keys from Auth0. The profile accepts
RS256, which Auth0 uses to sign tokens in this tutorial, andPS256.Auth0 reserves the
authorization_detailsclaim name, so the Post Login Action you created writes the RAR to the namespacedhttps://vault.hashicups.com/authorization_detailsclaim instead. Theauthorization_details_claimparameter tells Vault to read the RAR from that claim.$ cat > danielle-agent-profile.json <<EOF { "issuer_id": "${APP_ISS_URL}", "use_jwks": true, "actor_claim": "act.sub", "audiences": ["https://localhost:8200/v1"], "authorization_details_claim": "authorization_details", "clock_skew_leeway": 30, "config_id": "d2838e74-86b6-ef9f-5a8c-c0ff33e095fc", "enabled": true, "user_claim": "sub", "supported_algorithms": ["RS256", "PS256"], "unique_id_claim": "jti", "jwks_uri": "${APP_JWKS_URI}", "jwt_type": "access_token", "local": false, "no_default_policy": false, "optional_authorization_details": false, "profile_name": "danielle-agent-profile", "authorization_details_claim": "https://vault.hashicups.com/authorization_details" } EOFWrite the profile.
$ vault write sys/config/oauth-resource-server/danielle-agent-profile \ @danielle-agent-profile.jsonExample output:
Success! Data written to: sys/config/oauth-resource-server/danielle-agent-profileRead the profile to confirm that Vault created it, and note the generated
config_id.$ vault read sys/config/oauth-resource-server/danielle-agent-profileKey Value --- ----- actor_claim act.sub audiences [https://localhost:8200/v1] authorization_details_claim authorization_details clock_skew_leeway 30 config_id d2838e74-86b6-ef9f-5a8c-c0ff33e095fc enabled true issuer_id https://hashicups-dev.us.auth0.com jwks_uri https://hashicups-dev.us.auth0.com/.well-known/jwks.json jwt_type access_token local false no_default_policy false optional_authorization_details false profile_name danielle-agent-profile supported_algorithms [RS256 PS256] unique_id_claim jti use_jwks true user_claim subVerification: Confirm
enabledistrueandconfig_idis a valid UUID.Export the
config_idfor use in creating entity aliases.$ export APP_CONFIG_ID="d2838e74-86b6-ef9f-5a8c-c0ff33e095fc"
Create entities and aliases
Complete this section as Oliver, the Vault operator.
An on-behalf-of flow involves two identities:
- The subject: Danielle, with a broad baseline policy.
- The actor: the agent. Vault grants the agent the intersection of the user policy and the OBO ceiling policy, and the RAR claim can narrow it further.
Create a Vault entity to represent Danielle's human identity.
$ vault write identity/entity \ name="danielle-human" \ policies="danielle-developer"Example output:
Key Value --- ----- aliases <nil> id 1b75bc66-28e3-9126-5c18-c0ff33d83c5c name danielle-humanCreate a Vault entity to represent the agent identity, with no additional ACL policies.
$ vault write identity/entity name="pr-reviewer-agent"Example output:
Key Value --- ----- aliases <nil> id 0ca161e7-a74e-2827-f3a1-c0ff331f1599 name pr-reviewer-agentExport the entity IDs for the subject, Danielle, and the actor, Danielle's agent, from the command output.
$ export SUBJ_ID="1b75bc66-28e3-9126-5c18-c0ff33d83c5c" \ ACTOR_ID="0ca161e7-a74e-2827-f3a1-c0ff331f1599"Set the external IDs. These are the
subvalues Auth0 issues for the human and the agent. Danielle'ssubis the Auth0 user ID you exported in the Create the user step. Auth0 sets the agent'ssubto its client ID followed by@clients.$ export SUBJECT_EXTERNAL_ID="${SUBJECT_USER_ID}" \ ACTOR_EXTERNAL_ID="${AGENT_CLIENT_ID}@clients"Create the entity aliases that bind each external subject to its entity through the OAuth resource server mount.
Create the subject entity alias.
$ vault write identity/entity-alias \ name="${SUBJECT_EXTERNAL_ID}" \ canonical_id="${SUBJ_ID}" \ issuer="${APP_ISS_URL}" \ external_id="${SUBJECT_EXTERNAL_ID}"Example output:
Key Value --- ----- canonical_id 1b75bc66-28e3-9126-5c18-c0ff33d83c5c id 5d463c84-d185-90df-0caf-c0ff33d0e165Create the actor entity alias. You must set the
issuerfield on every OAuth resource server alias.$ vault write identity/entity-alias \ name="${ACTOR_EXTERNAL_ID}" \ canonical_id="${ACTOR_ID}" \ issuer="${APP_ISS_URL}" \ external_id="${ACTOR_EXTERNAL_ID}"Example output:
Key Value --- ----- canonical_id 0ca161e7-a74e-2827-f3a1-c0ff331f1599 id a89e280e-f4b8-fe5d-3e79-c0ff33c7366c
Register the agent in the Agent Registry
Complete this section as Oliver, the Vault operator.
Only the actor entity needs a registration record. Registering it marks the entity as an approved agent and attaches the OBO ceiling policies that cap what the agent can ever do. Vault requires this record before the agent can use OAuth credentials.
Create the registration payload defining the agent's display name, entity ID, and OBO ceiling policies.
$ cat > registration_payload.json <<EOF { "display_name": "pr-reviewer-agent", "entity_id": "${ACTOR_ID}", "description": "HashiCups PR review agent (on behalf of Danielle)", "ceiling_policies": ["agent-repo-reviewer-ceiling"] } EOFRegister the agent.
$ vault write agent-registry/register @registration_payload.jsonKey Value --- ----- display_name pr-reviewer-agent id 3487bf8a-62b9-db0a-2117-c0ff3370db84Read the registration back to confirm the OBO ceiling policies. Vault automatically adds the
defaultanddefault-ceilingpolicies unless you setno_default_ceiling_policy.$ vault read agent-registry/registration/display-name/pr-reviewer-agentKey Value --- ----- ceiling_policies [agent-repo-reviewer-ceiling default default-ceiling] creation_time 2026-06-24T16:07:36Z description HashiCups PR review agent (on behalf of Danielle) display_name pr-reviewer-agent entity_id 0ca161e7-a74e-2827-f3a1-c0ff331f1599 id 3487bf8a-62b9-db0a-2117-c0ff3370db84 last_updated_time 2026-06-24T16:07:51Z no_default_ceiling_policy false
The operator setup is complete. Vault now trusts Auth0 tokens, knows which entity is the human and which is the agent, and enforces the agent's OBO ceiling on every delegated request.
Understand the delegated RAR token
Complete this section as Danielle, the developer.
Rich Authorization Requests, defined in RFC 9396, enable fine-grained authorization by
allowing clients to specify detailed permissions in a request. In this
tutorial, the application sends the RAR as the authorization_details
parameter of an RFC 8693 token exchange request. The Custom Token Exchange
Action adds the act claim, and the Post Login Action copies the requested
RAR into the issued access token.
A delegated token from Auth0 has the following shape:
{
"iss": "https://hashicups-dev.us.auth0.com/",
"aud": "https://localhost:8200/v1",
"sub": "auth0|c0ff33a2dc1f8e9de0dd95d7",
"act": { "sub": "Dy2zGoztujFhcPDcX81NGGmc0ff33Z@clients" },
"https://vault.hashicups.com/authorization_details": [
{
"type": "vault:path_access",
"path": "hashicups-kv/data/repo/github-token",
"capabilities": ["read"]
}
],
"iat": 0, "exp": 0, "jti": "unique"
}
subidentifies the subject, Danielle. Vault maps it to their entity and baseline policies.act.subidentifies the actor, which is the LLM agent. Its presence is what makes this an on-behalf-of token. Vault maps it to the registered agent entity and its OBO ceiling policies.https://vault.hashicups.com/authorization_detailsis the RAR claim for the per-session scope. Auth0 reserves theauthorization_detailsclaim name, so the Post Login Action prefixes the claim name withhttps://vault.hashicups.com. Theauthorization_details_claimparameter in the OAuth resource server profile tells Vault to read the RAR from this claim.
The RAR claim is an array of objects, and each object uses exactly these fields:
| Field | Required value | Notes |
|---|---|---|
type | vault:path_access | Must match exactly. Vault denies a request with any other value. |
path | the exact Vault API path | For KV v2, include the data/ segment, for example hashicups-kv/data/repo/github-token. |
capabilities | an array of capabilities | One or more of read, create, update, delete, list, sudo, or deny (lowercase). |
Mint a delegated RAR token
Complete this section as Danielle, the developer.
The flow has three steps. You mint a token for the agent, mint a token for Danielle, and then exchange both tokens for a single delegated token that carries the RAR claim.
Mint a token for the agent using the client credentials grant.
$ oauth2c "https://${APP_DOMAIN}/authorize" \ --pkce \ --client-id "${AGENT_CLIENT_ID}" \ --client-secret "${AGENT_CLIENT_SECRET}" \ --audience https://localhost:8200/v1 \ --grant-type client_credentials \ --auth-method client_secret_basicThe terminal prints the token response and the decoded access token. The
subclaim is the agent client ID followed by@clients.Response: { "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6ImF0K2p3dCIs...", "expires_in": 86400, "token_type": "Bearer" } Access token: { "aud": "https://localhost:8200/v1", "client_id": "Dy2zGoztujFhcPDcX81NGGmc0ff33Z", "exp": 1787318567, "iat": 1787232167, "iss": "https://hashicups-dev.us.auth0.com/", "jti": "eQd55WuPmq1FL848hKbgHm", "permissions": [], "sub": "Dy2zGoztujFhcPDcX81NGGmc0ff33Z@clients" } SUCCESS Authorization completedCopy the
access_tokenvalue from the Response section and export it.$ export AGENT_TOKEN="<agent access token>"Mint a token for Danielle using the authorization code grant.
$ oauth2c "https://${APP_DOMAIN}/authorize" \ --pkce \ --client-id "${APP_CLIENT_ID}" \ --client-secret "${APP_CLIENT_SECRET}" \ --response-types code \ --response-mode query \ --audience https://localhost:8200/v1 \ --grant-type authorization_code \ --auth-method client_secret_basic \ --no-browseroauth2cprints a URL under Go to the following URL and waits for the callback.Go to the following URL: https://hashicups-dev.us.auth0.com/authorize?audience=https%3A%2F%2Flocalhost%3A8200%2Fv1&...Copy the URL and open it in a new private or incognito browser window. Sign in with the email address and password you set in the Create the user step. Close the private window after authentication succeeds.
The terminal prints the token response and the decoded access token.
Response: { "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6ImF0K2p3dCIs...", "expires_in": 86400, "token_type": "Bearer" } Access token: { "aud": "https://localhost:8200/v1", "client_id": "EBYeLHzuLqSeyUYLY7EC3hqRgvaiS9Dv", "exp": 1782402664, "iat": 1782316264, "iss": "https://hashicups-dev.us.auth0.com/", "jti": "baFC3mowpruhSr1JdDRhbJ", "permissions": [], "sub": "auth0|c0ff33a2dc1f8e9de0dd95d7" } SUCCESS Exchanged authorization code for access tokenVerification: Confirm the
subvalue matches$SUBJECT_USER_ID.Copy the
access_tokenvalue from the Response section and export it.$ export SUBJECT_TOKEN="<subject access token>"Exchange Danielle's subject token and the agent's actor token for a delegated token with a RAR claim scoped to read the repository token.
$ RAR='[{ "type": "vault:path_access", "path": "hashicups-kv/data/repo/github-token", "capabilities": ["read"] }]' $ export DELEG="$(curl -s --request POST \ --url "https://${APP_DOMAIN}/oauth/token" \ --header 'content-type: application/x-www-form-urlencoded' \ --data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \ --data-urlencode "client_id=${APP_CLIENT_ID}" \ --data-urlencode "client_secret=${APP_CLIENT_SECRET}" \ --data-urlencode 'audience=https://localhost:8200/v1' \ --data-urlencode "subject_token=${SUBJECT_TOKEN}" \ --data-urlencode 'subject_token_type=urn:vault:params:oauth:token-type:access_token' \ --data-urlencode "actor_token=${AGENT_TOKEN}" \ --data-urlencode 'actor_token_type=urn:vault:params:oauth:token-type:access_token' \ --data-urlencode "authorization_details=${RAR}" \ | jq -r '.access_token')"Decode the delegated token to confirm it carries the
actand RAR claims.$ jwt decode "$DELEG"Token header ------------ { "typ": "at+jwt", "alg": "RS256", "kid": "BsI1ZhsUTrrwvXJXJCW7F" } Token claims ------------ { "act": { "sub": "Dy2zGoztujFhcPDcX81NGGmc0ff33Z@clients" }, "aud": "https://localhost:8200/v1", "client_id": "EBYeLHzuLqSeyUYLY7EC3hqRgvaiS9Dv", "exp": 1787331084, "https://vault.hashicups.com/authorization_details": [ { "capabilities": [ "read" ], "path": "hashicups-kv/data/repo/github-token", "type": "vault:path_access" } ], "iat": 1787244684, "iss": "https://hashicups-dev.us.auth0.com/", "jti": "ewrrEPtssMaHuq5Rw2rWca", "permissions": [], "sub": "auth0|c0ff33a2dc1f8e9de0dd95d7" }Verification: Confirm
subis Danielle's user ID,act.subis the agent's@clientsID, and the RAR claim contains the repository token path. If$DELEGisnull, refer to Troubleshoot.
Verify enforcement
Complete this section as Danielle, the developer.
With a delegated token in $DELEG, prove that the subject baseline, OBO
ceiling, and per-session RAR work together. For each denial scenario, exchange
the same subject and actor tokens for a fresh delegated token with a different
RAR claim, and observe the result.
Use vault read and vault write against the explicit data/ paths rather
than vault kv get/vault kv put. The kv helper runs an unauthenticated
preflight against sys/internal/ui/mounts that does not recognize external
JWT tokens.
Row 1: ALLOW. Read the repository token with a RAR claim scoped to exactly that path. The subject allows it, the OBO ceiling allows it, and the RAR matches.
$ VAULT_TOKEN="$DELEG" vault read hashicups-kv/data/repo/github-token
Example output:
Key Value
--- -----
data map[token:ghp_demoRepoReadToken123]
metadata map[created_time:2026-06-16T18:36:33.963974Z ... version:1]
Row 2: Denied by the OBO ceiling. Mint a token that tries to write the repository token, then attempt the write. Danielle can write, but the OBO ceiling is read-only.
$ RAR='[{
"type": "vault:path_access",
"path": "hashicups-kv/data/repo/github-token",
"capabilities": ["update"]
}]'
$ export DELEG_WRITE="$(curl -s --request POST \
--url "https://${APP_DOMAIN}/oauth/token" \
--header 'content-type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \
--data-urlencode "client_id=${APP_CLIENT_ID}" \
--data-urlencode "client_secret=${APP_CLIENT_SECRET}" \
--data-urlencode 'audience=https://localhost:8200/v1' \
--data-urlencode "subject_token=${SUBJECT_TOKEN}" \
--data-urlencode 'subject_token_type=urn:vault:params:oauth:token-type:access_token' \
--data-urlencode "actor_token=${AGENT_TOKEN}" \
--data-urlencode 'actor_token_type=urn:vault:params:oauth:token-type:access_token' \
--data-urlencode "authorization_details=${RAR}" \
| jq -r '.access_token')"
Attempt the write with the new token.
$ echo '{"token":"x"}' | VAULT_TOKEN="$DELEG_WRITE" \
vault write hashicups-kv/data/repo/github-token -
Example output:
Code: 403. Errors:
* 1 error occurred:
* permission denied
Row 3: Denied by the OBO ceiling. Attempt to read a production secret. Danielle can read it, but the OBO ceiling allows only the repository path.
$ RAR='[{
"type": "vault:path_access",
"path": "hashicups-kv/data/prod/db-password",
"capabilities": ["read"]
}]'
$ export DELEG_PROD="$(curl -s --request POST \
--url "https://${APP_DOMAIN}/oauth/token" \
--header 'content-type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \
--data-urlencode "client_id=${APP_CLIENT_ID}" \
--data-urlencode "client_secret=${APP_CLIENT_SECRET}" \
--data-urlencode 'audience=https://localhost:8200/v1' \
--data-urlencode "subject_token=${SUBJECT_TOKEN}" \
--data-urlencode 'subject_token_type=urn:vault:params:oauth:token-type:access_token' \
--data-urlencode "actor_token=${AGENT_TOKEN}" \
--data-urlencode 'actor_token_type=urn:vault:params:oauth:token-type:access_token' \
--data-urlencode "authorization_details=${RAR}" \
| jq -r '.access_token')"
Attempt the read with the new token.
$ VAULT_TOKEN="$DELEG_PROD" vault read hashicups-kv/data/prod/db-password
Example output:
Code: 403. Errors:
* 1 error occurred:
* permission denied
Row 4: Denied by RAR. Use a token whose durable policies would allow the read, but whose RAR claim is scoped to the production path instead of the repository path. Even though subject ∩ OBO ceiling would permit the repository read, the RAR claim does not match and Vault denies it.
$ RAR='[{
"type": "vault:path_access",
"path": "hashicups-kv/data/prod/db-password",
"capabilities": ["read"]
}]'
$ export DELEG_RAR_PROD="$(curl -s --request POST \
--url "https://${APP_DOMAIN}/oauth/token" \
--header 'content-type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \
--data-urlencode "client_id=${APP_CLIENT_ID}" \
--data-urlencode "client_secret=${APP_CLIENT_SECRET}" \
--data-urlencode 'audience=https://localhost:8200/v1' \
--data-urlencode "subject_token=${SUBJECT_TOKEN}" \
--data-urlencode 'subject_token_type=urn:vault:params:oauth:token-type:access_token' \
--data-urlencode "actor_token=${AGENT_TOKEN}" \
--data-urlencode 'actor_token_type=urn:vault:params:oauth:token-type:access_token' \
--data-urlencode "authorization_details=${RAR}" \
| jq -r '.access_token')"
Attempt to read the repository token with the new token.
$ VAULT_TOKEN="$DELEG_RAR_PROD" vault read hashicups-kv/data/repo/github-token
Example output:
Code: 403. Errors:
* 1 error occurred:
* permission denied
These four results map directly to the three controls:
| Request | Subj | OBO ceiling | RAR | Result |
|---|---|---|---|---|
read repo/github-token | allow | allow | allow | ALLOW |
write repo/github-token | allow | deny | n/a | deny |
read prod/db-password | allow | deny | n/a | deny |
read repo/github-token | allow | allow | deny | deny |
Inspect the audit log
Complete this section as Danielle, the developer.
The audit device records the delegation and RAR metadata, which is the proof that the controls combined as designed. Find the response entry for the successful repository read.
$ grep '"type":"response"' ./vault/logs/vault-server-audit-log.json \
| grep 'repo/github-token' \
| jq 'select(.auth.policy_results.allowed==true)
| {granting_policies: .auth.policy_results.granting_policies,
metadata: .auth.metadata}' \
| tail -n 20
Example output:
{
"granting_policies": [
{ "type": "" },
{ "type": "" },
{ "name": "danielle-developer", "namespace_id": "root", "type": "acl" },
{ "name": "agent-repo-reviewer-ceiling", "namespace_id": "root", "type": "acl" }
],
"metadata": {
"actor_entity_id": "0ca161e7-a74e-2827-f3a1-c0ff331f1599",
"actor_entity_name": "pr-reviewer-agent",
"enterprise_token_audience": "[\"https://localhost:8200/v1\"]",
"enterprise_token_authorization_details": "[{\"capabilities\":\[\"read\"\],\"path\":\"hashicups-kv/data/repo/github-token\",\"type\":\"vault:path_access\"}]",
"enterprise_token_issuer": "https://hashicups-dev.us.auth0.com/",
"enterprise_token_metadata": "t1781792504"
}
}
Three fields in this entry make the design auditable:
actor_entity_idandactor_entity_nameappear only for on-behalf-of tokens. The delegation is visible and attributable to a specific agent.granting_policieslists both the subject policy (danielle-developer) and the OBO ceiling policy (agent-repo-reviewer-ceiling), making the intersection tangible.enterprise_token_authorization_detailsrecords the exact RAR scope for the call. During incident review, you can identify precisely what Vault permitted the agent to access on each request.
Refer to the audit log entry schema for the full list of OAuth JWT metadata fields.
Use the token with OpenCode
Complete this section as Danielle, the developer.
The final step replaces the unsafe environment-file pattern with a delegated
token. The integration uses a lightweight wrapper script to get a
per-session delegated RAR token from Auth0, exports it as VAULT_TOKEN, and
launches the agent.
The agent reads the repository token from Vault at session start instead of from disk.
Create a file named
run-agent.shwith the following content. The wrapper script exchanges Danielle's token and the agent's token for a delegated token scoped to the single secret the review session needs. The script readsAPP_DOMAIN,APP_CLIENT_ID,APP_CLIENT_SECRET,SUBJECT_TOKEN, andAGENT_TOKENfrom the environment you exported in earlier steps.run-agent.sh
#!/usr/bin/env bash set -euo pipefail export VAULT_ADDR="https://127.0.0.1:8200" export VAULT_CACERT="./tls/vault-ca.pem" RAR='[{ "type": "vault:path_access", "path": "hashicups-kv/data/repo/github-token", "capabilities": ["read"] }]' DELEG="$(curl -s --request POST \ --url "https://${APP_DOMAIN}/oauth/token" \ --header 'content-type: application/x-www-form-urlencoded' \ --data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \ --data-urlencode "client_id=${APP_CLIENT_ID}" \ --data-urlencode "client_secret=${APP_CLIENT_SECRET}" \ --data-urlencode 'audience=https://localhost:8200/v1' \ --data-urlencode "subject_token=${SUBJECT_TOKEN}" \ --data-urlencode 'subject_token_type=urn:vault:params:oauth:token-type:access_token' \ --data-urlencode "actor_token=${AGENT_TOKEN}" \ --data-urlencode 'actor_token_type=urn:vault:params:oauth:token-type:access_token' \ --data-urlencode "authorization_details=${RAR}" \ | jq -r '.access_token')" if [ -z "$DELEG" ] || [ "$DELEG" = "null" ]; then echo "Token exchange failed. Check the Auth0 tenant logs." >&2 exit 1 fi export VAULT_TOKEN="$DELEG" exec opencode run "$@"Make the script executable.
$ chmod +x run-agent.shConfigure the agent's skill or tool to fetch the repository token from Vault at the start of the session:
$ vault read hashicups-kv/data/repo/github-tokenLaunch a review session:
$ ./run-agent.sh "Can you read hashicups-kv/data/repo/github-token? Answer yes or no."
The agent now receives a token that is read-only, limited to the repository path, and scoped to a single secret. Every operation gets attributed to both Danielle and the agent in the audit log.
Clean up
Follow these steps to remove the artifacts and resources created in the tutorial lab scenario.
Stop containers and remove all data.
$ docker compose down --remove-orphansRemove the Vault data, logs, initialization file, and the files you created.
$ rm -rf tls/* logs/* vault/data vault/init/vault-init.json \ danielle-agent-profile.json registration_payload.json run-agent.shUnset the environment variables in your terminal session.
$ unset VAULT_LICENSE VAULT_ADDR VAULT_CACERT ACTOR_ID ACTOR_EXTERNAL_ID \ APP_DOMAIN APP_CLIENT_ID APP_CLIENT_SECRET APP_JWKS_URI APP_ISS_URL \ AGENT_CLIENT_ID AGENT_CLIENT_SECRET SUBJECT_USER_ID MGMT_TOKEN \ CTE_ACTION_ID APP_CONFIG_ID SUBJ_ID SUBJECT_EXTERNAL_ID AGENT_TOKEN \ SUBJECT_TOKEN RAR DELEG DELEG_WRITE DELEG_PROD DELEG_RAR_PRODRemove the Auth0 applications, API, user, Actions, and token exchange profile if you no longer need them.
Troubleshoot
Use this table of common symptoms, root causes, and potential fixes to help troubleshoot your lab setup.
| Symptom | Cause | Fix |
|---|---|---|
no alias found / error looking up entity on JWT auth | Entity alias created without issuer= | Recreate the alias with issuer="${APP_ISS_URL}". |
Token exchange returns null or an error instead of a token | Custom Token Exchange is not enabled for the application, the token exchange profile is missing, or the Action denied the request | Confirm the application token_exchange setting and the profile, then check the Auth0 logs. |
Token exchange fails with invalid_client | The application client ID is not in the Action's ALLOWED_CLIENT_IDS set | Add your APP_CLIENT_ID value to the Action and deploy it again. |
| Delegated token has no RAR claim | The Post Login Action is not in the post-login trigger | Add inject_rar_claims_post_login to the post-login trigger and click Apply. |
Management API returns 401 Unauthorized | The Management API token expired | Generate a new token from the API Explorer tab and export MGMT_TOKEN again. |
permission denied whenever a RAR claim is present | The RAR claim uses the wrong field names, or type is not vault:path_access | Use type=vault:path_access, path=<exact path>, capabilities=<array of capabilities>. |
| Auth0 returns an opaque token, not a JWT | Audience not requested | Pass --audience https://localhost:8200/v1. |
vault kv get fails with an external token but vault read works | the kv helper runs an unauthenticated preflight | Use vault read/vault write against the explicit data/ path. |
| OBO ceiling change appears ignored | OBO ceiling policies only restrict on-behalf-of (act) requests | Confirm that the token carries act.sub. |
| Issuer mismatch | Profile issuer_id versus JWT iss trailing slash or case | Vault trims the trailing slash and lowercases the value. Align the rest of the issuer. |
To investigate a failed token issuance or exchange in Auth0, use these sources:
Tenant logs: Navigate to Monitoring > Logs to review every event in your Auth0 tenant, including failed token exchanges.
Action logs: Output from
console.logstatements you add to an Action appears in the Action logs for that Action.
Summary
In this tutorial, you configured Vault Enterprise for agentic IAM, and registered an LLM coding agent in the Agent Registry with OBO ceiling policies.
You also minted delegated Rich Authorization Request tokens that scope the agent access to the intersection of a human user's baseline ACL policies, the OBO ceiling policies, and a per-session constraint.
You then confirmed enforcement and inspected the audit trail that makes every delegated action attributable.
Additional resources
- Read the concepts documentation:
- Read the API documentation for complete request and response schemas:
Next steps
Continue learning with the following resources.
Foundation skills
- Learn to build ACL policies for baseline and OBO ceiling permissions that define what humans and agents can access.
- Configure OIDC authentication to connect additional identity providers beyond Auth0.
Advanced patterns
- Explore Agent Registry concepts for designing ceiling policies for your own non-human identities.
- Implement cross-namespace agent delegation patterns for multi-tenant environments.
- Design custom ceiling policy frameworks that adapt to different agent types and use cases.
Production deployment
- Review OAuth resource server production deployment considerations including key rotation and token refresh strategies.
- Set up monitoring and alerting for agent activity using audit logs and metrics.
- Integrate with popular CI/CD platforms to secure pipeline agents.
Related use cases
This pattern applies to any agent requiring credentials:
- CI/CD pipeline agents accessing deployment secrets
- Monitoring agents reading health check credentials
- Automation tools performing scheduled maintenance
- Infrastructure agents managing cloud resources