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 and 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 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
subclaim, even as the audience changes per tenant. - Cryptographic least privilege – Each downstream call carries a token bound to one downstream service through the
audclaim. 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
subis the original caller, whoseaudis 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.
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 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_scopesclaim and stores bookings in Amazon DynamoDB partitioned by thesubclaim, 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_codelogin, 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_scopesclaim, and signs the OBO JWT. - Identity returns the OBO JWT to the Gateway.
- The Gateway calls the
/acmeroute 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’ssub, 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
statevalue and builds an Okta/authorizeURL withresponse_type=code, scope=openid emailgateway/invoke, and aredirect_uripointing 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
stateto defend against cross-site request forgery (CSRF), and exchanges the code for the inbound access token at Okta’s/v1/tokenendpoint. - The agent uses that access token (whose
subis 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
subclaim is preserved end to end, so audit logs and authorization decisions resolve to the original caller. - The
audclaim is rewritten to the tenant API, binding the token cryptographically to a single downstream service. - The
cidclaim 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.audiencevalue 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
audienceparameter 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 whoseJwtConfiguration.Audienceis the tenant’s audience. A token whoseaudclaim 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_tokenuser to their groups forscpfiltering. Don’t rely on access-policy scope rules to produce per-userscp. 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_prooferrors 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
cidclaim, notclient_id. Gateway’sallowedClientsmatches againstclient_id, which Okta access tokens don’t carry. UseallowedAudienceinstead, because Okta does emit theaudclaim. - The
subject_token_typeparameter must beaccess_token, notjwt. Several SDKs default tojwtfor RFC 8693 exchanges, and Okta rejects this withinvalid_request. Override the value throughcustomParameterson 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
urn:ietf:params:oauth:grant-type:token-exchangein its allowed grant types, in addition to being assigned in each tenant authorization server’s access policy. 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 anunauthorized_clienterror from Okta with little context about which control rejected the request. - The three-legged inbound login has its own setup requirements. The
redirect_urimust 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. Useprompt=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.audienceon every Gateway target andJwtConfiguration.Audienceon 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
subvalue 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
suband per-user claims, not the actor claim – The original caller is the principal. The delegate that performed the exchange is the actor. Application-layer authorization should resolve tosub. The actor claim (cidin Okta,actper 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 (cidfor Okta),aud, and the token’sjtiif 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_clienterrors from the tenant authorization server, anduser_claim_evaluation_failurerates 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.
