
Keeping agent identity in the token: two-step OBO with Bedrock AgentCore Gateway
A two-step On-Behalf-Of token exchange on Amazon Bedrock AgentCore Gateway that carries the user, the agent, and the Gateway in one token to the downstream API — with the token flow, IdP client setup, and the KMS-over-client-secret trade-off.
AI agents take action on behalf of a human, and both identities matter to the downstream authorization decision. Every call the agent makes has to answer two questions: which user is this for, and which agent is acting for them.
This post shows how to answer both using On-Behalf-Of (OBO) token exchange through Amazon Bedrock AgentCore Gateway, with an identity provider (IdP) issuing the tokens. The pattern is a two-step OBO flow that places the user and the agent in the final token the API receives.
The IdP used to validate these flows was IBM Verify .
What is OBO
OBO — On-Behalf-Of — is the OAuth 2.0 Token Exchange grant (RFC 8693 ). A client hands the identity provider (IdP) a token for a user and gets back a new token for that same user, re-issued for a different audience and, optionally, stamped with who is acting:
1
2
3
4
POST /token
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
subject_token=<the user's token>
actor_token=<who is acting for them>The user's identity stays put in
sub. The acting party lands in an act claim. If the incoming token already had an act, the IdP nests the old one under the new one — so the token carries the whole delegation chain, newest actor outermost.Why OBO matters for agent security
Without OBO, the only way to carry the user's identity to a downstream API is to forward the user's original token unchanged. For an agent calling tools, that creates three problems OBO solves:
- Replay. The user's token is minted for the agent. Forward it to a downstream API and that API — or anything that intercepts it — can replay it against the agent or any other service that shares the audience. OBO re-issues the token scoped to one API, so it cannot be reused elsewhere.
- Least privilege. OBO narrows scope at each hop. The user signs in with five scopes; the agent's exchange keeps three; the final token carries one. The API receives the minimum it needs, not the user's full grant.
- Accountability. The
actclaim records who acted for the user. Forward the raw token and every call looks like it came straight from the user — the audit trail cannot reconstruct which agent, or which gateway, was in the path.
OBO moves a user's identity across a trust boundary without over-sharing it. For an agent, that boundary is crossed on every tool call.
What is AgentCore Gateway
Amazon Bedrock AgentCore Gateway is a fully managed AI gateway that provides a single, secure entry point for agentic traffic — connecting agents to tools, to other agents, and to large language models. It converts APIs, Lambda functions, and existing services into Model Context Protocol (MCP) -compatible tools, and provides both inbound authentication (verifying agent identity) and outbound authentication (connecting to tools) in one service — eliminating weeks of custom integration and security code.
The Gateway relies on AgentCore Identity for both directions: an inbound JWT authorizer that validates incoming tokens (agent → Gateway), and outbound credential providers that authenticate the Gateway to the tools it calls (Gateway → tools). AgentCore Identity is a managed service that stores credentials and validates the tokens issued by the IdP.
As a developer, you register your tools as Gateway targets and configure the authentication method for each one. The Gateway exposes those tools to agents as MCP tools, and AgentCore Identity handles authentication on every call. This removes three pieces of undifferentiated heavy lifting:
- No auth code in the agent. The agent calls a tool. It never touches the token endpoint, holds a client secret, or implements RFC 8693. The exchange is configuration, not code.
- One enforcement point. Authorization, scope narrowing, logging, and policy live at the Gateway. You change the OBO configuration in one place, not in every agent.
- Consistency. Every tool call goes through the same exchange, scoped the same way. No agent can skip it.
AgentCore Gateway supports OBO flows. You attach an AgentCore Identity credential provider to a target and set it to
TOKEN_EXCHANGE, and the Gateway performs the OBO exchange:1
Agent --(user token)--> Gateway --(exchanged token)--> APIThe Gateway receives the user's token, asks AgentCore Identity to exchange it, and calls the API with the result.
The challenge
The challenge is that the Gateway runs the exchange under its own identity. The Agent only hands over the user's token, so whatever the IdP stamps into
act describes the Gateway, not the Agent that made the call.The final token carries the user, but the Agent's identity is lost. The downstream API cannot answer "which Agent read this record?", cannot grant one Agent write access and another read-only, and cannot block a misbehaving Agent without blocking every Agent behind the Gateway.
The solution: two-step OBO
The solution uses two OBO exchanges: one by the Agent, one by the Gateway. Together they preserve all three identities — the user making the call, the Agent acting on the user's behalf, and the Gateway that brokers the final exchange.
First, the user signs in with the IdP (IBM Verify). Sign-in returns
Token A, which carries the user in sub. AgentCore Identity validates Token A and authorizes its holder to call the Agent running in AgentCore Runtime .The Agent then runs an OBO exchange through AgentCore Identity. This requires the Agent to be registered as an app client in the IdP. The exchange returns
Token B, which keeps the user in sub and adds the Agent's identity in the act claim. This is the one piece of code the developer adds — a single call to AgentCore Identity for the first exchange:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def _agent_obo_token():
"""Exchange the user's token for one that names this agent (agent-side OBO)."""
from bedrock_agentcore.runtime.context import BedrockAgentCoreContext
# The Runtime puts the agent's workload access token in the request context.
wat = BedrockAgentCoreContext.get_workload_access_token()
if not wat:
raise AgentOboError("AGENT_OBO_PROVIDER is set but there is no workload access token")
client = boto3.client("bedrock-agentcore", region_name=REGION)
resp = client.get_resource_oauth2_token(
workloadIdentityToken=wat,
resourceCredentialProviderName=AGENT_OBO_PROVIDER, # the agent's IdP client
scopes=AGENT_OBO_SCOPES,
oauth2Flow="ON_BEHALF_OF_TOKEN_EXCHANGE",
)
token = resp.get("accessToken")
if not token:
raise AgentOboError("agent-side OBO exchange returned no access token")
return token # this is Token B — sub = user, act.sub = agentThe Agent calls the Gateway with
Token B. AgentCore Identity validates Token B for inbound authentication, then the Gateway runs its own OBO exchange through Identity and receives Token C. Token C keeps the user in sub, carries the Gateway as the outer actor in act.sub, and nests the Agent in act.act.sub. The downstream API receives Token C — all three identities in one token.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
┌────────┐ sign in ┌────────────┐
│ User │ ─────────▶ │ IBM Verify │
└────────┘ └────────────┘
│ Token A (user) ▲
▼ │ ┌─────────────────────┐
┌──────────────────┐ └─│ AgentCore Identity │ validates inbound tokens
│ AgentCore Runtime│ ───────────│ runs OBO exchanges │ AND runs both OBO exchanges
│ (the agent) │ │ at IBM Verify │ at Verify (auth happens here)
└──────────────────┘ └─────────────────────┘
│ Token B (sub = user, act.sub = AGENT)
▼ ▲
┌──────────────────┐ │ OBO exchange 2 (Gateway authenticates here)
│ AgentCore Gateway│ ─────────┘ via AgentCore Identity
└──────────────────┘ Token C (sub = user, act.sub = GATEWAY, act.act.sub = AGENT)
│ Token C
▼
┌──────────────────┐
│ Downstream API │ validates Token C: user in sub, agent in act.act.sub
└──────────────────┘Both exchanges run through AgentCore Identity — it holds each party's credential, authenticates that party to Verify, and validates the inbound token at both the Runtime and the Gateway.
| # | Step | Who runs it | Result |
|---|---|---|---|
| 1 | User signs in to Verify | user | Token A — sub = user |
| 2 | OBO exchange 1: Agent exchanges Token A, naming itself as actor (via its credential provider) | AgentCore Runtime | Token B — sub = user, act.sub = Agent |
| 3 | Agent calls the Gateway with Token B | AgentCore Runtime | — |
| 4 | OBO exchange 2: Gateway exchanges Token B, nesting the Agent under its own actor (provider verify-obo) | AgentCore Gateway | Token C — sub = user, act.sub = Gateway, act.act.sub = Agent |
| 5 | Gateway calls the API with Token C | AgentCore Gateway | API validates and authorizes |
The same flow as a sequence diagram:

The token at each step
Decoded payloads from a validated run. Volatile fields (
exp, iat, jti) omitted; signing is RS256 by Verify.Token A — the user's token after sign-in:
1
2
3
4
5
6
7
{
"sub": "646009TEPA",
"preferred_username": "test-user",
"scope": "weather.read weather.write tours.book insurance.approve",
"aud": ["a05723c9-... (sign-in app)"],
"iss": "https://<tenant>.verify.ibm.com/oauth2"
}Token B — after the Agent's exchange. The Agent is now the actor;
sub is still the user:1
2
3
4
5
6
{
"sub": "646009TEPA",
"act": { "sub": "097a7fb2-... (the AGENT's client)" },
"scope": "weather.read tours.book insurance.approve",
"iss": "https://<tenant>.verify.ibm.com/oauth2"
}Token C — after the Gateway's exchange, as the API receives it. The Gateway is the outer actor; the Agent is nested one level in:
1
2
3
4
5
6
7
8
9
10
11
12
{
"sub": "646009TEPA",
"act": {
"sub": "bc6b7510-... (the GATEWAY's client)",
"act": {
"sub": "097a7fb2-... (the AGENT's client)"
}
},
"aud": ["bc6b7510-... (the Gateway's client)"],
"scope": "weather.read",
"iss": "https://<tenant>.verify.ibm.com/oauth2"
}Reading the chain:
sub is the user, act.sub is the Gateway (ran exchange 2), and act.act.sub is the Agent (ran exchange 1). Scope narrowed 5 → 3 → 1 down the path, so the API receives least privilege. The Agent is back in the token — the API reads act.act.sub to identify which Agent called.Verify builds the nesting automatically (RFC 8693 §4.1): because Token B already carried an
act, the Gateway's exchange keeps it and nests it under the new outer actor. No custom mapping rule.Registering each party as a client in the IdP
The mechanism that makes this work is that every exchanging party is registered as its own OAuth client in the IdP. The Agent is a client. The Gateway is a client. They are distinct application registrations with distinct client IDs.
That is what gives each party a stable, IdP-governed identity to stamp into
act:- When the Agent runs exchange 1, it authenticates to Verify as its client, and Verify writes that client ID into
act.sub(097a7fb2-...). - When the Gateway runs exchange 2, it authenticates as its client, Verify writes that client ID into the new outer
act.sub(bc6b7510-...), and nests the Agent's client underneath.
The delegation chain in Token C is a direct consequence of each actor being a first-class client identity in the IdP, authenticating as itself on its own exchange. Register one client per Agent and one for the Gateway, and the IdP distinguishes them downstream. The audience (
aud) of each issued token is the client that issued it, which is what the downstream authorizer validates against.Security considerations
App developers write their own auth for exchange 1
This is the trade-off of the two-step flow. Exchange 2 is free — the Gateway's credential provider runs it with no code. Exchange 1 is not. To put the Agent in the token, the Agent runs its own OBO exchange before calling the Gateway, which means the developer owns the Agent-side exchange code (shown above) that plain Gateway-managed OBO did not require.
If the API only needs the user, skip the first exchange and let the Gateway run the single exchange. If the API needs to authorize, rate-limit, or audit per Agent, the first exchange provides that — and the Agent, not the Gateway, has to run it.
Use KMS, not a client ID + secret
Each client — the Agent and the Gateway — proves itself to the IdP when it runs its exchange. There are two ways to hold that credential, and they are not equal: a private key held in AWS KMS , or a client ID and secret.
| Private key in KMS ✅ recommended | Client ID + secret ⚠ not recommended | |
|---|---|---|
| How it authenticates | AgentCore asks KMS to sign a short-lived JWT assertion per call | AgentCore stores a client secret and sends it every call |
| Credential at rest | asymmetric KMS key — the private half never leaves KMS | a long-lived secret, stored in AWS and known to the IdP |
| If it leaks | nothing reusable; a captured assertion expires in minutes | a standing credential, valid until someone rotates it |
| Audit | every use is a logged kms:Sign call in CloudTrail | no per-use record |
| Rotation | swap key/certificate — no secret to distribute | generate and redistribute a new secret |
| IdP client auth method | private_key_jwt | client_secret_post |
The client secret is a long-lived credential. If it leaks, it stays valid until someone detects and rotates it, and the audit trail does not attribute individual uses.
KMS removes that. The private key never leaves KMS — AgentCore asks it to sign a fresh, short-lived assertion per call. There is nothing reusable to store or leak, every signature is a logged
kms:Sign call, and rotation is a key swap with nothing handed around. When the Gateway and the Agent both run on KMS, no client secret exists anywhere in the path.Give each client its own key, so they rotate and revoke independently.
Conclusion
OBO carries a user's identity across a trust boundary safely, and AgentCore Gateway makes it effortless. But the convenience comes with a cost: the Gateway runs the exchange under its own identity, so the final token names the Gateway, not the Agent that made the call.
The two-step OBO flow closes that gap. The Agent runs its own exchange first and the Gateway nests it, so the final token carries the full chain — user → Gateway → Agent — and the downstream API can authorize on all three. The cost is the Agent-side exchange code for the first exchange, and the credential it needs should be a KMS key, not a long-lived client secret.
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article