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.
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 thesub
claim, even as the audience changes per tenant.Cryptographic least privilege– Each downstream call carries a token bound to one downstream service through theaud
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.
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 whosesub
is the original caller, whoseaud
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.
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.
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 namedTravelBot 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 APIandGlobex Travel APIare 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-userauthorized_scopes
claim and stores bookings in Amazon DynamoDB partitioned by thesub
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’ssub
, and returns the tenant-specific response. - The response flows back through API Gateway and AgentCore Gateway to the agent.
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 withresponse_type=code
, scope=openid emailgateway/invoke
, and aredirect_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.
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
.
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.
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.
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– ThecustomParameters.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 theaudience
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 whoseJwtConfiguration.Audience
is the tenant’s audience. A token whoseaud
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.
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 thesubject_token
user to their groups forscp
filtering. 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 producesinvalid_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’scid
claim, notclient_id
.allowedClients
matches againstclient_id
, which Okta access tokens don’t carry. UseallowedAudience
instead, because Okta does emit theaud
claim. The Several SDKs default tosubject_token_type
parameter must beaccess_token
, notjwt
.jwt
for RFC 8693 exchanges, and Okta rejects this withinvalid_request
. Override the value throughcustomParameters
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 underTrusted Serversin 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 anurn: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. Theredirect_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. Useprompt=login
(or a private browser window) when demonstrating multiple users, so a cached session does not silently authenticate the wrong identity.
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– SetcustomParameters.audience
on every Gateway target andJwtConfiguration.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 anysub
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 tosub
and per-user claims, not the actor claimsub
. 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– Logsub
, the actor (cid
for Okta),aud
, and the token’sjti
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, anduser_claim_evaluation_failure
rates on Expression claims. A spike on these usually indicates a misconfiguration on a tenant rather than a runtime defect.
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.