# Implement on-behalf-of token exchange for multi-tenant agents with Amazon Bedrock AgentCore Gateway

> Source: <https://aws.amazon.com/blogs/machine-learning/implement-on-behalf-of-token-exchange-for-multi-tenant-agents-with-amazon-bedrock-agentcore-gateway/>
> Published: 2026-07-13 17:27:40+00:00

[Artificial Intelligence](https://aws.amazon.com/blogs/machine-learning/)

# Implement on-behalf-of token exchange for multi-tenant agents with Amazon Bedrock AgentCore Gateway

When you deploy generative AI agents into multi-tenant production architectures, you face a specific identity problem: when an agent calls a downstream API on behalf of a user, whose identity travels with the call? Running the call as the agent’s service identity collapses the audit trail, because every downstream system must trust the agent unconditionally. Forwarding the user’s token unchanged turns every downstream tool into a confused deputy. Neither option scales when one agent fronts many tenants and the user is not present at the moment of the tool call.

The OAuth 2.0 Token Exchange specification (RFC 8693) addresses this exact problem, and Amazon Bedrock AgentCore Identity supports it natively as a credential-provider grant type. [Building multi-tenant agents with Amazon Bedrock AgentCore](https://aws.amazon.com/blogs/machine-learning/building-multi-tenant-agents-with-amazon-bedrock-agentcore/) and [Apply fine-grained access control with Bedrock AgentCore Gateway interceptors](https://aws.amazon.com/blogs/machine-learning/apply-fine-grained-access-control-with-bedrock-agentcore-gateway-interceptors/) establish the conceptual foundation for on-behalf-of (OBO) token exchange in agentic systems. This post is the implementation guide. It walks through a complete multi-tenant OBO setup against Okta, shows the JSON Web Token (JWT) claim transformations on each hop, and demonstrates how audience binding produces defense in depth that scales across tenants.

The reference implementation, *TravelBot*, is a multi-tenant booking assistant that serves two example tenants (Acme and Globex). The reference implementation for this post will be available in the [aws-samples/sample-obo-flow-poc](https://github.com/aws-samples/sample-obo-flow-poc) repository after publication.

The OBO pattern is essential whenever an agent fronts multiple downstream services or tenants and the inbound token’s audience differs from any single downstream API. For a single-tenant agent where the inbound audience already matches the downstream service, direct token forwarding can be sufficient. The rest of this post focuses on the multi-tenant case.

# Introducing on-behalf-of token exchange in AgentCore Identity

Amazon Bedrock AgentCore Identity supports OAuth 2.0 Token Exchange (RFC 8693) as a native credential-provider grant type. With this capability, AgentCore Gateway can transparently exchange an inbound user token for a new, audience-bound token before invoking a downstream tool, without requiring the agent itself to implement the exchange.

This capability provides the following benefits:

**Identity propagation across tenant boundaries**– The original caller’s identity is preserved end to end through the`sub`

claim, even as the audience changes per tenant.**Cryptographic least privilege**– Each downstream call carries a token bound to one downstream service through the`aud`

claim. A token issued for one tenant can’t be used at another.**No agent-side exchange logic**– The agent code obtains a single inbound token and invokes tools. AgentCore performs every exchange.** Standards-based**– The implementation is built on the RFC 8693 token-exchange grant. Authorization servers that support the same grant type with a compatible request shape can serve as a credential provider.

# The confused deputy problem in agentic AI

Consider an agent that handles requests like “show me my bookings” for users from many different tenants. Three implementation choices are available, and only one of them is correct.

**Service-account impersonation**– The agent authenticates as itself and asserts the user’s identity in a request header or path parameter. Every downstream API must trust the agent unconditionally. A compromised agent can act as any user against any tenant. This is the textbook confused deputy.**Direct user-token forwarding**– The agent reuses the inbound user token to call downstream APIs. This works only when the inbound token’s audience already matches the downstream API. That condition is rarely true in multi-tenant systems and never true when the agent fronts a tool gateway.**On-behalf-of token exchange**– The agent’s authorization broker exchanges the inbound subject token for a new token whose`sub`

is the original caller, whose`aud`

is the downstream API, and whose signature comes from an authorization server the downstream API trusts. The result is a token cryptographically scoped to a single downstream call on behalf of a single user.

OBO is the only choice that preserves the user’s identity end to end, enforces least privilege at the audience boundary, and produces a token the downstream API can validate independently without trusting the agent. Implementing RFC 8693 requires alignment across the agent runtime, the authorization servers, and the downstream APIs. When any one of them is misconfigured, the security posture degrades silently.

Amazon Bedrock AgentCore Gateway and AgentCore Identity remove that coordination burden. The Gateway intercepts the tool call, identifies the target tenant, and instructs Identity to perform the exchange against the tenant’s authorization server before the downstream call is issued.

# Impersonation compared to on-behalf-of

In an OBO exchange, the inbound token’s `sub`

claim is preserved while the `aud`

claim is rewritten to the downstream service. The actor of the exchange is recorded in a separate claim (`act`

per RFC 8693, `cid`

in Okta), so the downstream API can answer two questions from a single token: *who is being acted on behalf of?* (the `sub`

claim) and *who is performing the action?* (the actor claim). Authorization decisions belong on `sub`

. Audit logs and rate-limiting decisions belong on the actor. For a deeper conceptual treatment of impersonation versus on-behalf-of, refer to [Apply fine-grained access control with Bedrock AgentCore Gateway interceptors](https://aws.amazon.com/blogs/machine-learning/apply-fine-grained-access-control-with-bedrock-agentcore-gateway-interceptors/).

The following diagram contrasts the two patterns using TravelBot’s booking tools. In direct user-token forwarding, the inbound and downstream tokens are the same token. In OBO, each hop carries a distinct token bound to a different tenant’s booking API.

Figure 1. Direct user-token forwarding vs. on-behalf-of token flow for TravelBot’s booking tools.

In the direct-forwarding pattern (top), the agent forwards the user’s token unchanged. The token’s `aud`

claim was issued for the agent’s API, so the downstream tools must either skip audience validation or accept whatever audience the upstream service hands them. Both options reintroduce the confused deputy. In the on-behalf-of pattern (bottom), AgentCore exchanges the token at each hop for a new token whose `aud`

claim is bound to a single downstream service and whose `scp`

is reduced to the minimum required. The `sub`

claim is preserved across hops, so audit and authorization decisions still resolve to the original user, while the actor claim records AgentCore as the delegate that performed the exchange.

# Solution overview

Throughout this post, the following identifiers refer to specific Okta resources in the TravelBot reference.

Identifier |
Role |
TravelBot Provider |
Okta authorization server that issues the inbound JWT to the agent. |
ACME Travel API |
Okta authorization server that mints OBO tokens for the Acme tenant. |
Globex Travel API |
Okta authorization server that mints OBO tokens for the Globex tenant. |
TravelBot Agent Client |
Okta API Services app for the agent’s machine-to-machine path (legacy/testing). |
TravelBot User Client |
Okta OpenID Connect (OIDC) app for the 3-legged user login. |
AgentCore Delegate |
Okta app whose credentials AgentCore Identity uses to perform the token exchange. |

The TravelBot reference implementation uses Okta on both sides of the exchange. One Okta authorization server (TravelBot Provider) authenticates the agent. Two more (ACME Travel API and Globex Travel API) mint OBO tokens for each tenant. All three are Okta *Custom* authorization servers. Okta’s built-in Org authorization server does not support custom audiences or scopes and cannot serve this pattern.

The same roles can be played by other identity providers (IdPs) with equivalent capabilities. Because RFC 8693 is implemented at the authorization-server layer, the same architecture should work with other authorization servers that support the token-exchange grant. The exact request shape AgentCore Identity sends (`subject_token_type`

, audience, actor-token presence, and client authentication method) is set per credential provider through the `customParameters`

map, so adapting to a different IdP is a configuration change, not a code change. We recommend that you verify against your specific deployment.

Auth0 (through its Custom Token Exchange feature) and Keycloak (with its token-exchange feature enabled per realm) advertise compatible RFC 8693 support and work with `grantType: TOKEN_EXCHANGE`

. AWS IAM Identity Center supports a conceptually similar pattern through its *Trusted Token Issuer* feature with a different request and response shape than RFC 8693. This differs from Okta’s *Trusted Servers* relationship described earlier. Microsoft Entra ID’s on-behalf-of flow uses `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`

with `requested_token_use=on_behalf_of`

(RFC 7523) rather than the RFC 8693 grant type; AgentCore Identity supports this flow natively through `grantType: JWT_AUTHORIZATION_GRANT`

on the credential provider.

Amazon Cognito user pools can serve as the provider IdP that authenticates the inbound agent call. Confirm the current grant-type support against the AgentCore Identity documentation if you plan to use Cognito for the consumer-side OBO role.

AgentCore Identity supports both public and virtual private cloud (VPC) private connectivity to tenant authorization servers, including identity providers hosted inside your VPC. See [Connect to private identity providers](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/identity-private-idp.html) in the AgentCore developer guide for configuration patterns.

The architecture has six components:

**Provider authorization server**– Issues the inbound JWT that the agent presents to the Gateway. In TravelBot this is the Okta authorization server named*TravelBot Provider*.**AgentCore Gateway**– Validates the inbound JWT against the provider’s JSON Web Key Set (JWKS), routes the tool invocation to the correct tenant target, and orchestrates the OBO exchange.**AgentCore Identity**– Holds the delegate client credentials and executes the RFC 8693 token exchange against the tenant authorization server.**Per-tenant authorization servers**– Mint OBO tokens scoped to each tenant’s audience. In TravelBot,*ACME Travel API*and*Globex Travel API*are two distinct Okta authorization servers, each with its own audience and access policy.**Per-tenant API surface**– An Amazon API Gateway HTTP API with one JWT authorizer per tenant. Each authorizer validates issuer, audience, and required scopes, providing the last line of defense against cross-tenant token reuse.**Tenant business logic**– An AWS Lambda function that receives the validated OBO token and reads the claims to make tenant-specific decisions. It enforces write permission from the per-user`authorized_scopes`

claim and stores bookings in Amazon DynamoDB partitioned by the`sub`

claim, so a user can only ever read their own records.

The rest of this post walks through these components in the order a request traverses them. The following diagram summarizes the request flow:

Figure 2. End-to-end OBO request flow with AgentCore Gateway.

The workflow consists of the following steps:

- A user authenticates at the provider authorization server through the three-legged
`authorization_code`

login, and the agent receives the inbound JWT, audience-bound to the Gateway. - The agent invokes a tool through Model Context Protocol (MCP), presenting the inbound JWT as a bearer token.
- The Gateway retrieves the provider’s JWKS, validates the JWT signature, and confirms the audience matches
`travelbot-provider`

. - The Gateway selects the credential provider associated with the Acme target and asks Identity for an OBO token.
- Identity sends an RFC 8693 token-exchange request to Acme’s authorization server. The subject token is the inbound JWT, and the requested audience is
`https://api.acme-travel.example`

. - Okta verifies that Acme’s authorization server trusts the provider issuer, applies the access policy on the delegate client, computes the per-user
`authorized_scopes`

claim, and signs the OBO JWT. - Identity returns the OBO JWT to the Gateway.
- The Gateway calls the
`/acme`

route on API Gateway, presenting the OBO JWT as the bearer token. - The API Gateway JWT authorizer validates issuer, audience, and required scopes before forwarding the request to the AWS Lambda function.
- The Lambda function decodes the OBO claims, enforces write permission from
`authorized_scopes`

, queries DynamoDB partitioned by the user’s`sub`

, and returns the tenant-specific response. - The response flows back through API Gateway and AgentCore Gateway to the agent.

# The three-legged inbound login

Because TravelBot authenticates a real user, the agent runs an OAuth 2.0 `authorization_code`

flow before it talks to the Gateway:

- The agent generates a random
`state`

value and builds an Okta`/authorize`

URL with`response_type=code`

, scope=openid email`gateway/invoke`

, and a`redirect_uri`

pointing at a local callback listener. - The agent starts the callback listener, then opens the browser.
- The user authenticates at Okta.
- Okta redirects to the callback with an authorization code and the
`state`

. - The callback listener verifies
`state`

to defend against cross-site request forgery (CSRF), and exchanges the code for the inbound access token at Okta’s`/v1/token`

endpoint. - The agent uses that access token (whose
`sub`

is the authenticated user) as the bearer token for every Gateway call.

In a production web application, the local callback listener is replaced by a normal route on the application’s backend behind a load balancer, and the `redirect_uri`

becomes that public HTTPS endpoint. The exchange logic is identical. Only where the redirect lands changes.

# Token transformation deep dive

The following table summarizes how each JWT claim is transformed between the inbound token and the OBO token. The `sub`

claim is preserved end to end. Everything else is rewritten or added by the tenant authorization server.

Claim |
Phase 1 (inbound) |
Phase 3 (OBO) |
Change |
`iss` |
Provider authorization server | Tenant authorization server | Rewritten |
`aud` |
`travelbot-provider` |
`https://api.acme-travel.example` |
Rewritten |
`sub` |
`alice@acme-travel.example` |
`alice@acme-travel.example` |
Preserved |
`cid` |
Provider client (TravelBot Agent Client) | Delegate client (AgentCore Delegate) | Rewritten (actor) |
`scp` |
[openid email `gateway/invoke` ] |
[`booking/read` `booking/write` ] |
Rewritten |
`authorized_scopes` |
— | `booking/read` |
New |

Three claims carry the security story:

- The
`sub`

claim is preserved end to end, so audit logs and authorization decisions resolve to the original caller. - The
`aud`

claim is rewritten to the tenant API, binding the token cryptographically to a single downstream service. - The
`cid`

claim records the delegate that performed the exchange, separating the actor from the caller.

A note on claim naming for cross-IdP environments: Okta emits scopes as the `scp`

array claim and the actor as `cid`

. RFC 8693 and OAuth 2.0 use `scope`

(a space-delimited string) and `act`

(a nested object). When normalizing audit logs across multiple IdPs, account for both shapes rather than assuming the spec form.

In the TravelBot reference, the inbound token is obtained through the three-legged `authorization_code`

login, so the `sub`

claim is the authenticated human user. In a machine-to-machine flow (`client_credentials`

grant), `sub`

would instead be the caller application’s client ID.

**Phase 1 — Inbound token, issued by the provider authorization server:**

The agent obtains this token through the three-legged `authorization_code`

login. It is bound to the Gateway’s expected audience (`travelbot-provider`

) and carries a single coarse scope, `gateway/invoke`

, which authorizes calling the Gateway and nothing further.

**Phase 2 — RFC 8693 token exchange request, sent by AgentCore Identity to the Acme tenant authorization server:**

```
POST /oauth2/aus<acme-id>/v1/token HTTP/1.1
Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token=<inbound JWT from Phase 1>
&subject_token_type=urn:ietf:params:oauth:token-type:access_token
&audience=https://api.acme-travel.example
&scope=booking/read+booking/write
&client_id=<delegate-client-id>
&client_secret=<delegate-client-secret>
```

Two parameters are non-obvious. The `subject_token_type`

parameter must be `access_token`

. AgentCore Identity defaults `subject_token_type`

to `jwt`

because RFC 8693 allows several valid token-type URIs and different IdPs accept different ones. Okta requires `access_token`

. Override the default by setting `subject_token_type`

in the credential provider’s `customParameters`

map (see Step 3 of the Implementation walkthrough). Without the override, Okta rejects the exchange with `invalid_request`

.

The `audience`

parameter is also mandatory for Okta and must match the audience configured on the tenant authorization server exactly. This match is what binds the resulting token to a single downstream API.

The AgentCore Delegate authenticates inline by presenting its `client_id`

and `client_secret`

in the exchange request body (using `CLIENT_SECRET_POST`

per the credential provider configuration). No preceding `client_credentials`

round-trip is required. The exchange is a single call against the tenant authorization server.

**Phase 3 — OBO token, issued by the tenant authorization server:**

Compared to the Phase 1 token, `iss`

and `aud`

now point at Acme’s authorization server and API, `sub`

is unchanged, `cid`

is now the delegate’s client ID, and `scp`

carries the booking scopes the API expects. The `authorized_scopes`

claim carries the user’s effective permission, computed per-user from group membership; its purpose is explained in the next section. The token is valid only at `https://api.acme-travel.example`

.

A captured example: The block below is a sample OBO token issued during a TravelBot demo run for alice@acme-travel.example, a user in Okta’s acme-readonly group. This token is entirely fictitious and cannot be used for authentication. The middle of the JWT body is truncated for readability:

Decoded claims:

Note the asymmetry between the two scope-related claims. `scp`

lists both `booking/read`

and `booking/write`

because the credential provider asks for both on every exchange, but `authorized_scopes`

carries only `booking/read`

. That per-user value is computed by the Expression claim from Alice’s group membership. A user in `acme-fullaccess`

would receive an identical `scp`

and `authorized_scopes`

set to “`booking/read`

`booking/write`

”. The resource server reads `authorized_scopes`

, which is why the Lambda allows reads for Alice but rejects writes.

The following diagram shows what happens when an OBO token issued for one tenant is presented to a different tenant’s API. The tenant boundary is enforced cryptographically by the token’s `aud`

claim, not by application logic.

Figure 3. Cross-tenant token rejection at API Gateway.

A token whose `aud`

claim is `https://api.acme-travel.example`

cannot pass the `/globex`

route’s JWT authorizer, because that authorizer’s `JwtConfiguration.Audience`

is `https://api.globex-travel.example`

. API Gateway returns HTTP 401 before the Lambda function is invoked. No application code runs.

## Two legs, two grant types

A multi-tenant OBO deployment combines two distinct OAuth interactions, and it helps to name them up front because they behave differently:

The inbound leg (user to Gateway): The user authenticates interactively. TravelBot uses a three-legged `authorization_code`

login, so the inbound token’s `sub`

claim is the real human user (for example, `alice@acme-travel.example`

). A machine-to-machine `client_credentials`

grant is also valid here. In that case `sub`

is the calling application’s client ID, and the rest of the flow is identical.

The exchange leg (Gateway/Identity to the tenant authorization server): This is the RFC 8693 token exchange, and the authorization server processes it as a machine-to-machine grant: it’s authenticated by the delegate’s client credentials, and the user is present only as the `subject_token`

payload, not as an interactive session. This distinction matters in practice: because the exchange is treated as machine-to-machine (M2M), the authorization server doesn’t run the interactive group-to-scope evaluation it would perform during a login. The next section unpacks the consequence.

## A note on per-user scopes during token exchange

A natural goal in a multi-tenant system is to grant each user different scopes by role. For example, an `acme-readonly`

group receives `booking/read`

, and an `acme-fullaccess`

group receives both `booking/read`

and `booking/write`

. The intuitive way to express this is an Okta access-policy rule per group that caps the granted scopes.

This doesn’t work during the token-exchange grant, and the reason traces back to the two-legs distinction. During an interactive `authorization_code`

login, Okta evaluates the user’s group membership against the access-policy rules and drops scopes the user isn’t entitled to. The OBO exchange, however, is processed as a machine-to-machine grant: it is authenticated by the delegate’s client credentials, and the user is present only as the `subject_token`

payload. Okta doesn’t map that subject user back to their groups for the purpose of scope filtering. The OBO token’s `scp`

claim therefore comes back containing every scope the client requested, regardless of the user’s group, and a read-only user’s token is indistinguishable from a full-access user’s token on `scp`

. The System Log entry for the exchange shows `grantedScopes`

equal to `requestedScopes`

, which is the diagnostic indicator.

The workaround uses a claim rather than a scope. Although Okta doesn’t filter `scp`

per user during the exchange, it does evaluate an Expression-type claim against the subject user at mint time, and an Expression claim can read group membership. Add a custom claim on each tenant authorization server that computes the user’s effective permission:

- Name:
`authorized_scopes`

- Token type: Access Token
- Value type: Expression
- Value (Acme):
`isMemberOfGroupName("acme-fullaccess") ? "booking/read booking/write" : (isMemberOfGroupName("acme-readonly") ? "booking/read" : "")`

- Include in: Any scope

The OBO token then carries both a permissive `scp`

(informational) and an `authorized_scopes`

claim that reflects the user’s real entitlement. The resource server makes its decision on `authorized_scopes`

. Use `isMemberOfGroupName(...)`

for the group check. A bare `Groups.contains(...)`

expression fails to evaluate during the exchange and aborts the entire mint with `user_claim_evaluation_failure`

.

The Lambda treats `authorized_scopes`

as the source of truth for mutating operations:

This is the standard division of labor for fine-grained authorization: the IdP authenticates the user and asserts their attributes, and the resource server makes the allow/deny decision. Two alternatives can achieve the same result. An Okta Token Inline Hook calls an external endpoint during the mint and patches the `scp`

claim based on the user’s groups, keeping `scp`

authoritative at the cost of hosting a hook endpoint. Or, if the agent layer already knows the user’s entitlement, it can request only the scopes that user is allowed, so the exchange grants exactly that set.

The following diagram shows where the per-user decision is made. The exchange (M2M) returns a permissive `scp`

, the Expression claim computes `authorized_scopes`

from the user’s group, and the resource server authorizes on that claim.

Figure 4. Per-user authorization derived from the `authorized_scopes`

Expression claim rather than `scp`

.

# Per-user data isolation with the sub claim

Identity propagation is only valuable if downstream systems act on it. The TravelBot Lambda stores bookings in DynamoDB partitioned by the user’s identity:

- Partition key (
`pk`

):`"{tenant}#{sub}"`

, for example,`acme#alice@acme-travel.example`

- Sort key (
`booking_id`

): the booking ID

On a read, the Lambda issues a Query scoped to the caller’s `pk`

. On a write, it puts an item under the same `pk`

. The effect is that one user can’t retrieve another user’s bookings. This is not because of an application-layer filter, but because the query is constructed from the caller’s own `sub`

claim, which is signed by Okta and verified at the API Gateway authorizer before the Lambda runs. The OBO `sub`

claim becomes the primary key of the data model, which is what “the `sub`

claim as a trustworthy principal” means in practice.

Figure 5. Data isolation by partitioning on the `sub`

claim. A user’s query can only reach rows under their own partition key.

At scale, partitioning by `sub`

can produce hot partitions for power users. For high-volume workloads, consider write-sharding the partition key (for example, `acme#alice@acme-travel.example#{shard}`

) and querying across shards on read.

# Implementation walkthrough

Implementing OBO on AgentCore Gateway involves three operations using the AWS SDK for Python (Boto3) against the `bedrock-agentcore-control`

API: creating the Gateway with an inbound authorizer, creating one credential provider per tenant, and attaching one target per tenant with the audience parameter set. The relationship between these three resources determines which authorization server is used for the inbound check, which one performs the exchange, and which downstream API receives the resulting OBO token.

Onboarding a new tenant requires creating one credential provider and one Gateway target with the new tenant’s audience. No agent code changes are needed.

Figure 6. AgentCore configuration relationships.

The Gateway holds the inbound authorizer. Each target binds an OpenAPI tool surface to a credential provider. Each credential provider holds the delegate credentials for one tenant authorization server. The three responsibilities are deliberately separated so that audience binding, scope reduction, and IdP rotation remain independent operations.

## Step 1: Create the Gateway with a custom JWT authorizer

The inbound authorizer validates the provider’s JWT before tool invocations reach the orchestration layer. For Okta-issued tokens, configure `allowedAudience`

. Okta places the client identity in the `cid`

claim rather than `client_id`

, so Gateway’s `allowedClients`

mechanism doesn’t apply.

The discovery URL points at the *provider* authorization server, not at a tenant authorization server. The inbound authorizer’s responsibility is to verify that the agent is authorized to use the Gateway, not to authorize specific downstream calls.

## Step 2: Create one OAuth2 credential provider per tenant

Each tenant authorization server is registered as a distinct credential provider. The credential provider holds the delegate’s client credentials (the AgentCore identity that performs the token exchange) and the discovery configuration of the tenant authorization server. Most modern IdPs publish an OpenID Connect document at `/.well-known/openid-configuration`

that AgentCore can consume directly through `discoveryUrl`

. For pure OAuth 2.0 authorization servers that publish only an RFC 8414 metadata document at `/.well-known/oauth-authorization-server`

, configure the credential provider with the static `authorizationServerMetadata`

shape instead of `discoveryUrl`

.

The `actorTokenContent: NONE`

setting instructs Identity to perform the exchange with only the subject token plus delegate client authentication, with no actor token. This shape matches Okta’s expected request format. Repeat the call for each tenant.

## Step 3: Create one Gateway target per tenant

The target binds an OpenAPI tool surface to a credential provider and configures the per-call OBO exchange parameters. The `customParameters`

map is where the audience and the corrected `subject_token_type`

are injected into every exchange:

After these three operations are complete, the agent code itself contains no token-exchange logic. It acquires a provider token, opens an MCP session against the Gateway, and invokes tools. The Gateway and Identity perform the exchange transparently on every tool call.

# Defense in depth through audience binding

Multi-tenant agentic systems must make sure that a token issued for one tenant cannot be used to access another tenant’s resources. In TravelBot, this principle is enforced in three independent locations. Any one of them would help block a cross-tenant attempt.

**At the Gateway target**– The`customParameters.audience`

value is set per target. A target serving Acme tools cannot mint a token with the Globex audience.**At the tenant authorization server**– Okta validates that the`audience`

parameter in the exchange request is registered on the authorization server before signing the OBO token.**At the API Gateway JWT authorizer**– Each route (`/acme`

,`/globex`

) is bound to an authorizer whose`JwtConfiguration.Audience`

is the tenant’s audience. A token whose`aud`

claim does not match is rejected with HTTP 401 before the Lambda function is invoked.

Three-layer enforcement is the security OBO provides. The `aud`

constraint is encoded in the token itself, no application-layer code is required to enforce it, and every component on the path can verify it independently. Each layer fails closed by default. A misconfiguration on a single layer rejects the request rather than silently allowing it through.

# Common pitfalls

The TravelBot implementation surfaced a set of issues that consistently appear in OBO integrations against Okta and are worth knowing about when implementing this pattern:

**Group-based scope capping is bypassed during token exchange.** Because Okta processes the OBO grant as machine-to-machine, it doesn’t map the`subject_token`

user to their groups for`scp`

filtering. Don’t rely on access-policy scope rules to produce per-user`scp`

. Use a per-user Expression claim (`authorized_scopes`

) enforced at the resource server, an inline token hook, or a precise client-side scope request.**DPoP must be disabled on both the provider and delegate Okta applications.** Demonstrating Proof-of-Possession (DPoP) binds a token to a private key the original client holds. AgentCore Identity is a token relay and doesn’t hold that key, so leaving DPoP required produces`invalid_dpop_proof`

errors at exchange time. Disabling DPoP gives up holder-of-key protection: a stolen bearer token can be replayed until it expires. Compensate by issuing short-lived OBO tokens, enforcing TLS on every hop, and tightly binding tokens to a single audience so a stolen token is useful at exactly one downstream service.- Okta carries the client identity in the
Gateway’s`cid`

claim, not`client_id`

.`allowedClients`

matches against`client_id`

, which Okta access tokens don’t carry. Use`allowedAudience`

instead, because Okta does emit the`aud`

claim. **The** Several SDKs default to`subject_token_type`

parameter must be`access_token`

, not`jwt`

.`jwt`

for RFC 8693 exchanges, and Okta rejects this with`invalid_request`

. Override the value through`customParameters`

on the Gateway target. For example,`customParameters={"subject_token_type": "urn:ietf:params:oauth:token-type:access_token"}`

.**The provider authorization server must be registered as a trusted issuer on each tenant authorization server.** This is configured under*Trusted Servers*in the Okta admin console for every tenant authorization server. The trust direction is one-way: each tenant authorization server must trust the provider, but the provider does not need to trust the tenants. Without this trust relationship, even a correctly constructed exchange request fails because the tenant authorization server refuses to accept a foreign-issued subject token.**The Delegate Okta application must list** Both controls are required. The app-level grant declares that the client is permitted to request token exchange. The access policy declares that this specific authorization server will honor the request. Missing either side produces an`urn:ietf:params:oauth:grant-type:token-exchange`

in its allowed grant types, in addition to being assigned in each tenant authorization server’s access policy.`unauthorized_client`

error from Okta with little context about which control rejected the request.**The three-legged inbound login has its own setup requirements.** The`redirect_uri`

must be registered as a Login redirect URI on the OIDC application and sent without over-encoding (preserve : and /). The user, or a group the user belongs to, must be assigned to the OIDC application, and the provider authorization server’s access-policy rule must list that application as an allowed client. Use`prompt=login`

(or a private browser window) when demonstrating multiple users, so a cached session does not silently authenticate the wrong identity.

# Best practices

When deploying OBO with AgentCore Gateway in production, the following practices are recommended:

**One credential provider and one delegate client per tenant**– Avoid sharing credential providers across tenants. The per-tenant boundary keeps credential rotation, scope changes, and tenant offboarding as independent operations.**Bind audiences explicitly**– Set`customParameters.audience`

on every Gateway target and`JwtConfiguration.Audience`

on every API Gateway authorizer. Don’t rely on defaults.**Issue short-lived OBO tokens**– Configure tenant authorization servers to issue OBO tokens with the shortest time to live (TTL) the application can tolerate. Short lifetimes are the primary mitigation for the bearer-token replay risk introduced by disabling DPoP.**Treat the AgentCore Delegate’s client secret as the most sensitive credential in the system**– A leaked delegate secret allows a unauthorized user to mint OBO tokens for any`sub`

value at every tenant authorization server the delegate is assigned to.**Rotate and scope the delegate secret tightly**– Rotate the delegate secret on a regular cadence, scope the delegate to the minimum set of tenants, and store the secret in AWS Secrets Manager with rotation enabled rather than in Parameter Store.**Make authorization decisions on**– The original caller is the principal. The delegate that performed the exchange is the actor. Application-layer authorization should resolve to`sub`

and per-user claims, not the actor claim`sub`

. The actor claim (`cid`

in Okta,`act`

per RFC 8693) belongs in audit logs.**Store IdP credentials in AWS Secrets Manager or AWS Systems Manager Parameter Store**– Reference them by Amazon Resource Name (ARN) in AWS CloudFormation or AWS Cloud Development Kit (AWS CDK). Never embed them in source.**Emit claim names, not raw tokens, into application logs**– Log`sub`

, the actor (`cid`

for Okta),`aud`

, and the token’s`jti`

if present. Never log the raw Authorization header value or the bearer token string. A leaked log line containing a live OBO token is equivalent to a credential disclosure for the lifetime of that token.**Plan for token-exchange overhead**– Each OBO exchange contacts the tenant authorization server. AgentCore Identity might cache OBO tokens within their lifetime. Verify the current behavior in the AgentCore documentation. Issue OBO tokens with the longest TTL the security model allows to maximize token reuse. The tenant authorization server’s rate limits also apply to the exchange endpoint, so treat them as part of your tool-call performance budget.**Surface exchange failures gracefully**– When the tenant authorization server is unreachable or rejects the exchange, the Gateway returns the underlying error to the agent. Map common cases (`invalid_request`

,`unauthorized_client`

, audience mismatch) to user-friendly messages in the agent layer rather than surfacing raw OAuth errors to end users.**Monitor token-exchange health as a production signal**– Track exchange success rate, audience-mismatch 401s at API Gateway,`unauthorized_client`

errors from the tenant authorization server, and`user_claim_evaluation_failure`

rates on Expression claims. A spike on these usually indicates a misconfiguration on a tenant rather than a runtime defect.

# Conclusion

On-behalf-of token exchange is the correct identity pattern for multi-tenant AI agents, and Amazon Bedrock AgentCore Gateway operationalizes it without requiring the agent itself to implement RFC 8693. By combining Gateway’s audience-bound credential providers with API Gateway’s per-tenant JWT authorizers, you preserve user identity end to end, enforce least privilege at the audience boundary, and produce an audit trail that distinguishes the delegate from the user. The operational benefit is concrete: consistent per-user audit trails, reduced scope of impact when a downstream tool or its token is compromised, and tenant onboarding by configuration alone. The `sub`

claim is a trustworthy principal that downstream services can pass to a fine-grained authorization layer such as Amazon Verified Permissions, the AWS policy-based fine-grained authorization service for resource-level decisions.

To get started, clone the TravelBot reference implementation, populate the `/travelbot/okta/*`

SSM parameters per the README, run `setup_infra.py`

to provision the AWS resources, and run the agent against an Acme or Globex tenant. The full end-to-end flow runs in a single AWS account with one Okta tenant.
