Knowing who your agent is acting for, with AgentCore Identity
TL;DR;
This post puts both halves of AgentCore Identity to work for one agent. Its inbound auth on the runtime validates the token a customer signed in with, and its outbound side, a workload identity, an on-behalf-of credential provider and the token vault, brokers that token for a short-lived, audience-restricted token for the order service, so the gateway sees only the minted token and a Cedar policy refuses any call whose customer differs from it. Cognito's token endpoint does not offer the exchange grant that AgentCore Identity brokers against, so the target is built the way AWS's own sample builds it, a small front door in front of a second Cognito pool whose triggers verify the customer's token and control what Cognito mints, with an audience, a confidential client, a five-minute token and fail-closed triggers added on top.
SOURCE CODE - All code for this post is available at: https://github.com/levantar-ai/demos/tree/main/agentcore/05-identity
Be warned that this post is a step up from the four before it. Each of those centred on one AgentCore capability and a small change to the agent's code. This one adds a workload identity, a credential provider, a token-exchange front door, a second Cognito pool with four Lambda triggers, two more secrets, an SSM parameter and a change to what the gateway trusts, and every one of them has a way to be wrong that looks like working. The sections below say what each is for, what it refuses and what it still does not guarantee. The token exchange itself is a teaching component, not production-grade. It exists to show the AgentCore Identity integration; in production the exchange is done by a managed IdP with a supported on-behalf-of integration, which needs none of the pool, the triggers or the front door.
Longer version
This series is building one thing, the order support agent for Brightwell, a small online retailer of outdoor kit that ships with DPD and Royal Mail. Posts 01 to 04 gave it a runtime, a tool that looks orders up, memory and a sandbox. None of them knew who was asking, and post 02 said so at the time, that the gateway established a legitimate client was calling, not that it was entitled to a particular order.
Closing that gap is the whole of this post, and it matters because filtering in the agent's own code is not an authorisation boundary. The tempting fix, giving the agent a broad credential and filtering results in its own code, is the one AWS's Well-Architected Agentic AI Lens tells you not to reach for.
The traditional approach of granting the agent broad credentials and relying on application-level filtering (such as adding WHERE clauses to queries) creates a single point of failure. A more resilient design moves authorization enforcement out of the agent's application code and into the infrastructure layer wherever possible.
https://docs.aws.amazon.com/wellarchitected/latest/agentic-ai-lens/agentsec03.html
AgentCore Identity has two halves. Inbound Auth validates the token a caller presents to a runtime or a gateway. Outbound Auth gives the agent tokens for the things it calls, through a workload identity, credential providers and a token vault. Relaying the customer's Cognito token to the gateway uses only the inbound half. Relaying an inbound token can be appropriate when that token was issued for the downstream resource, but this customer token has no downstream audience at all, and relaying it provisions no AgentCore Identity resource. This design uses both halves. The agent asks AgentCore Identity for a token for the order service, on behalf of the customer, and AgentCore Identity brokers an exchange of the customer's inbound token for one that a second Cognito pool mints. That is the on-behalf-of flow. It suits a background agent because, where the provider has already established the delegation, no further interactive consent is asked for on each downstream call. This demo's exchange applies a fixed first-party policy and has no consent screen at all.

1 - Inbound, the runtime checks the customer
The Cognito pool from post 02 keeps a public client for customers, who sign
in with a password and get an access token. The runtime validates that token
before the agent sees the request, through AgentCore Identity's inbound auth,
which is the CUSTOM_JWT authorizer on the runtime.
authorizer_configuration {
custom_jwt_authorizer {
discovery_url = local.discovery_url
allowed_clients = [aws_cognito_user_pool_client.customers.id]
}
}
AWS describes configuring the runtime this way as implementing authentication "using OAuth and JWT bearer tokens with AgentCore Identity", Inbound Auth being its name for the half that validates callers.
This section shows you how to implement authentication and authorization for your agent runtime using OAuth and JWT bearer tokens with AgentCore Identity. You'll learn how to set up Cognito user pools, configure your agent runtime for JWT authentication (Inbound Auth), and implement OAuth-based access to third-party resources (outbound Auth).
https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-oauth.html
The Authorization header is on the runtime's request header allowlist, so
the agent receives the validated token. It reads the username claim as the
customer, refuses anything that is not an access token, and never takes the
customer from the request body. This part is not where the post's subject
lives.
NOTE: the runtime's authorizer is built to accept an end user's token, so in a real system the customer's own app, or a backend for it, would invoke the runtime carrying that token. This demo has the customer call the runtime directly, which changes nothing about the token or the checks and keeps the subject on the identity mechanism rather than the application wiring.
2 - On behalf of the customer, the agent's token comes from AgentCore Identity
Here is the change. The agent does not send the customer's Cognito token to the gateway. It asks AgentCore Identity for a token for the order service, and AgentCore Identity brokers the exchange on the customer's behalf, with the exchange in the next section doing the minting. AWS's description of the flow is exactly the property wanted.
Amazon Bedrock AgentCore Identity supports On-Behalf-Of (OBO) token exchange, enabling agents and other workloads—such as MCP servers—to exchange an inbound user access token for a new, scoped access token that targets a downstream resource server. As the exchange converts a token issued for one audience directly into a token for a different downstream audience, your agents can access protected resources on behalf of authenticated users without triggering additional consent flows.
https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/on-behalf-of-token-exchange.html
Two calls do it. The first turns the customer's inbound token into a workload access token, which represents the agent acting for that customer. The second uses it to request the resource token, and AgentCore Identity brokers the exchange with the credential provider.
workload_token = client.get_workload_access_token_for_jwt(
workloadName=os.environ["WORKLOAD_NAME"],
userToken=inbound_jwt,
)["workloadAccessToken"]
result = client.get_resource_oauth2_token(
workloadIdentityToken=workload_token,
resourceCredentialProviderName=os.environ["OBO_PROVIDER_NAME"],
scopes=[os.environ["ORDERS_SCOPE"]],
oauth2Flow="ON_BEHALF_OF_TOKEN_EXCHANGE",
)
Three AgentCore Identity resources sit behind that. A workload identity for the agent, declared explicitly so its name is known when the runtime is created. A credential provider configured for on-behalf-of exchange, which authenticates the exchange with a client credential. And the token vault, which the provider lives under. The client credential itself ends up in three places, a Secrets Manager secret AgentCore Identity manages for the provider, a second Secrets Manager secret the exchange front door validates against, and the Terraform state that generated it, which is a reason to keep that state encrypted and tightly controlled.
resource "aws_bedrockagentcore_workload_identity" "agent" {
name = "demos_agentcore_05_agent"
}
resource "aws_cloudcontrolapi_resource" "obo_provider" {
type_name = "AWS::BedrockAgentCore::OAuth2CredentialProvider"
desired_state = jsonencode({
Name = "demos_agentcore_05_obo"
CredentialProviderVendor = "CustomOauth2"
Oauth2ProviderConfigInput = {
CustomOauth2ProviderConfig = {
OauthDiscovery = { DiscoveryUrl = "${local.exchange_issuer}/.well-known/openid-configuration" }
ClientId = local.exchange_client_id
ClientSecret = random_password.exchange_client_secret.result
ClientAuthenticationMethod = "CLIENT_SECRET_BASIC"
OnBehalfOfTokenExchangeConfig = {
GrantType = "TOKEN_EXCHANGE"
TokenExchangeGrantTypeConfig = { ActorTokenContent = "NONE" }
}
}
}
})
}
NOTE: the provider is created through Cloud Control rather than
aws_bedrockagentcore_oauth2_credential_provider, because that resource in the AWS provider, as of 6.64, does not modelon_behalf_of_token_exchange_config. The CloudFormation type does.NOTE:
GetResourceOauth2Tokenreads the provider's client secret in the caller's context, so the runtime role needssecretsmanager:GetSecretValueon the secret AgentCore Identity manages for the provider, namedbedrock-agentcore-identity!default/oauth2/<provider>-…. Without it the first call fails with an access-denied on that secret.
3 - The exchange, built the way AWS's sample builds it
On-behalf-of exchange is RFC 8693, and it needs an authorisation server that implements that grant. Cognito's token endpoint does not. It accepts exactly three grant types.
Must be
authorization_codeorrefresh_tokenorclient_credentials.
https://docs.aws.amazon.com/cognito/latest/developerguide/token-endpoint.html
AWS publishes a sample that closes that gap without leaving Cognito,
sample-cognito-oauth2-token-exchange, and this post's exchange follows its
architecture. The sample puts a token-exchange endpoint on API Gateway and
Lambda, which authenticates the calling service with a client secret and
verifies the user's token against the pool that issued it, and delegates the
minting to a second Cognito user pool through that pool's custom
authentication flow. The pool's own Lambda triggers verify the user's token
again and add the user's identity to the token Cognito issues.
This sample demonstrates how to implement RFC 8693 OAuth 2.0 Token Exchange using Amazon Cognito with a true delegation pattern. The solution enables services to act on behalf of users while maintaining distinct service identities and implementing the principle of least privilege.
https://github.com/aws-samples/sample-cognito-oauth2-token-exchange
That arrangement has two properties worth having. AWS manages the Cognito pool's signing keys and publishes its discovery document and JWKS, rather than the front door holding or using a signing key. And the endpoint cannot make Cognito issue a token unless the pool's triggers accept the custom authentication flow. It can only start that flow in the exchange pool as a fixed service user, with the customer's token as the challenge answer, and the pool's triggers decide whether Cognito issues anything and what it says. A compromised endpoint still needs a valid customer token to get a token out.
What is taken from the sample is that architecture, not its configuration,
and the differences are material. The sample rotates its exchanges across
24 service identities for throughput; this demo has one. It accepts an ID
token as a fallback subject; this demo accepts an access token only. Its
pre-token enrichment returns a plain token when the exchange metadata is
absent; this demo's triggers refuse. Its pool also has a second client with
password and refresh flows; this demo has one app client with
ALLOW_CUSTOM_AUTH as its sole explicit flow, and its pre-token trigger
separately refuses refresh-token generation.
And it fronts a REST API with a separate authorizer; this demo's front door
checks the client secret itself. This demo follows the sample's
architecture and changes those controls.
Three things are then added on top, each of which the sample leaves to you.
The first addition is an audience. The sample's exchanged token has no
aud, because Cognito access tokens do not include one by default, and its
production guidance says so.
Restrict the token audience/resource (RFC 8693
audience/resource) so exchanged tokens cannot be replayed against other downstreams, and enforce fine-grained, per-user authorization at the resource (for example with Amazon Verified Permissions).
https://github.com/aws-samples/sample-cognito-oauth2-token-exchange#security-considerations
Cognito's pre-token trigger may add aud to an access token on one
condition, that its value is the app client of the session.
You can add an
audclaim to access tokens, but its value must match the app client ID of the current session.
https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-pre-token-generation.html
So the exchange pool has one app client per downstream, and the orders
client's id is the minted token's aud. A second downstream would get a
second client and tokens that name it. The second addition is a
confidential client. The sample's admin client is public, so anyone who learned its id could run the custom
flow against Cognito directly and skip the endpoint. Here the client has a
secret, the front door computes SECRET_HASH from it, and of the workload
roles in this stack only the front door's may read the Secrets Manager copy;
the same secret is also in the Terraform state and readable by any principal
allowed to describe the app client, and those are inside the compromise
boundary. The third addition is a five-minute token lifetime, Cognito's
minimum validity, in place of the sample's hour.
The front door's own job is small. It is a narrow token-exchange endpoint
that implements the request AgentCore Identity sends, with RFC 8693's
parameters, and neither it nor the discovery document it serves claims
general RFC 8693 or OIDC conformance. It authenticates the caller with the
client secret AgentCore Identity's provider holds, refuses any parameter
outside that contract, verifies the customer's token itself so a bad one
never costs a Cognito flow, and runs AdminInitiateAuth and
AdminRespondToAuthChallenge with the customer's token as the answer and,
again, in the client metadata.
started = cognito.admin_initiate_auth(
UserPoolId=pool, ClientId=client, AuthFlow="CUSTOM_AUTH",
AuthParameters={"USERNAME": user, "SECRET_HASH": secret_hash},
)
answered = cognito.admin_respond_to_auth_challenge(
UserPoolId=pool, ClientId=client, ChallengeName="CUSTOM_CHALLENGE",
Session=started["Session"],
ChallengeResponses={"USERNAME": user, "ANSWER": subject_token, "SECRET_HASH": secret_hash},
ClientMetadata={"subject_token": subject_token, "grant": TOKEN_EXCHANGE_GRANT},
)
One thing in that call is the sample's compromise, kept here and named. The customer's token goes to Cognito twice, as the challenge answer, which the verify trigger sees, and in the client metadata, which is the only channel the pre-token trigger has. Cognito's API reference is explicit about that field.
When you use the ClientMetadata parameter, note that Amazon Cognito won't do the following: Store the ClientMetadata value. Validate the ClientMetadata value. Encrypt the ClientMetadata value. Don't send sensitive information in this parameter.
A bearer token is sensitive information, and this demo puts one there because the sample does and because it keeps the demo to five functions. The right shape for a real exchange is a single-use handle in the metadata, with the verify trigger writing the verified claims to a short-lived encrypted record under that handle and the pre-token trigger consuming it once. That is a store and a few more lines, and it is the part of this exchange not to copy as written.
The pool's four triggers do the rest, and every one of them fails closed.
define runs the state machine, one challenge, then tokens or failure.
create issues the challenge. verify validates the answer against the
customer pool, strictly, and refuses unless the answer is byte for byte the
token in the client metadata, which is the copy the last trigger reads, so
one customer's token cannot be answered with and another's minted for.
pretoken validates that copy again, because Cognito passes client metadata
through unvalidated, and then writes the claims.
event["response"] = {
"claimsAndScopeOverrideDetails": {
"accessTokenGeneration": {
"claimsToAddOrOverride": {
"aud": client_id,
"customer_id": claims["username"],
"customer_sub": claims["sub"],
},
"scopesToAdd": [ORDERS_SCOPE],
"scopesToSuppress": ["aws.cognito.signin.user.admin"],
},
},
}
Two claims Cognito will not let a trigger change are sub and username.
They stay the service user's, one fixed user that every exchange through
this front door runs as, so the token's subject is that service identity
and the customer travels as customer_id. That is the sample's "distinct
service identities" and the reverse of RFC 8693's act shape, where the
subject stays the user; the token carries no claim about which AgentCore
workload asked for it. Cedar reads a claim either way.
Be precise about what this does not buy. Whoever can change the triggers, the pool, its app client or its service user controls what the exchange pool mints. The front door's role can mint for any customer whose token it is given, which is its job, and so can anyone holding the app client's secret, which is in Secrets Manager and in the Terraform state. Once issued, the minted token remains usable until its own five-minute expiry. Its lifetime is not capped to the customer token's remaining lifetime, and customer sign-out, disablement or revocation does not invalidate it. And the front door's discovery document is a shim that tells AgentCore Identity where the token endpoint is, not an OIDC provider document; the gateway uses the exchange pool's real one. A managed IdP with a supported on-behalf-of integration is still the production answer; this is the smallest teaching implementation used here, with the trust and replay limits above.
4 - The gateway trusts the exchange pool, and Cedar still decides
The gateway's authorizer is pointed at the exchange pool's own discovery
document, a genuine Cognito one, rather than at the customer pool. The
customer's access token has no aud, whereas the minted token carries the
orders client as aud, so the gateway validates by allowed_audience.
authorizer_configuration {
custom_jwt_authorizer {
discovery_url = "https://cognito-idp.${var.aws_region}.amazonaws.com/${aws_cognito_user_pool.exchange.id}/.well-known/openid-configuration"
allowed_audience = [aws_cognito_user_pool_client.orders.id]
}
}
That has a consequence worth stating plainly. The gateway is configured for
the exchange pool and that audience, so the customer's own token is not
accepted at the gateway at all, and relaying it is not a weaker option the
agent might fall back to, it does not work. Be equally plain about the
converse. The gateway accepts any correctly signed token from the exchange
pool with that audience and cannot tell whether AgentCore Identity brokered
it. The normal agent path obtains it through AgentCore Identity, but the
front door is also callable directly by anyone holding the provider's client
secret and a valid customer token, and the runtime role can read that
secret, because GetResourceOauth2Token reads it in the caller's context. A
direct caller still needs a customer's valid token to be issued that
customer's customer_id, so this does not let one customer become another.
NOTE: for this flow, AgentCore Identity's credential provider uses the front door's discovery URL to locate
/token, and the exchange pool's own document pointstoken_endpointat Cognito's, which does not offer the grant. So the front door serves a small document of its own for the provider, withtoken_endpointpointing at itself andjwks_uriat the exchange pool, and the live run shows the provider calling that endpoint. It is a compatibility shim, not an OIDC provider document. The gateway does not use it; it validates the minted token against the exchange pool's Cognito discovery document.
Per-customer enforcement is the Policy in AgentCore engine from the first version, unchanged, evaluating a Cedar policy on every tool call.
For every tool invocation, the policy engine evaluates all applicable policies against the request to determine whether to allow or deny access. The engine enforces default-deny and forbid-wins semantics automatically.
When a AgentCore Gateway uses OAuth authorization, the principal is created from the JWT token's
subclaim. OAuth principals support tags that contain JWT claims such as username, scope, role, etc.
https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/policy-core-concepts.html
JWT claims are exposed as principal tags, including the customer_id claim
used here, and the rule is the one line it always was, now on customer_id. The principal itself is the
token's sub, the exchange pool's service user; the customer is the tag.
permit(
principal is AgentCore::OAuthUser,
action == AgentCore::Action::"orders___list_orders",
resource == AgentCore::Gateway::"${aws_bedrockagentcore_gateway.orders.gateway_arn}"
) when {
principal.hasTag("customer_id") &&
principal.getTag("customer_id") == context.input.customer_id
};
A matching forbid guards it, so a broad permit added in a later post cannot
widen it, forbid winning over any permit.
NOTE: the engine validates a policy's action against the gateway target's tool schema, so the permit must be created after the target, and a forbid validated before any permit exists is refused as overly restrictive, so it must be created after the permit. Both are
depends_onin the config.
5 - Running it
The whole path works as follows. The runtime validates the customer's Cognito token, the exchange front door and then the exchange pool's triggers validate it again during the exchange, and the gateway validates the minted token before Cedar evaluates the call.

A customer signs in and calls the agent, and the happy path looks like every earlier post, because the exchange happens inside it.
$ curl "$INVOKE_URL" -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "X-Amzn-Bedrock-AgentCore-Runtime-Session-Id: $SESSION" \
-d '{"prompt": "list my orders"}'
{"result": {"customer_id": "c-1000", "orders": [{"order_id": 1033, ...}, ...]}}
The token that reached the gateway is worth looking at. Exchanging c-1000's Cognito token at the front door directly, to inspect the result, gives this.
header {"alg": "RS256", "kid": "TvIiDULuRXM0iy7NC3MrMWzCRlgDWZDO6+WYHscJp4U="}
claims iss https://cognito-idp.us-east-1.amazonaws.com/us-east-1_xM9xiUfea
sub 74386448-1081-7039-67b7-010085d46183
username orders-agent
client_id 5ujh25q6q73kqq2u1aietapviq
aud 5ujh25q6q73kqq2u1aietapviq
customer_id c-1000
customer_sub d478b4f8-8081-70a9-259f-f35d390bdcaf
scope orders/read
token_use access
exp-iat 300 seconds
Signed by the exchange pool with Cognito's own key. Compared with relaying
the customer's token, this one carries the orders client as its audience,
which the gateway is configured for, expires after five minutes, names the
exchange pool's fixed service user as its subject and the customer as
customer_id, and the customer's credential itself never reaches the
gateway or the tool. The front door's log shows what AgentCore Identity
sends. It is grant_type, subject_token, subject_token_type and
scope, with no audience or resource, so the audience is the exchange's
decision, not the caller's.
The interesting calls are the ones that should not work, made against the gateway directly with that minted token.
$ TOKEN="$MINTED" python3 probe_gateway.py c-1000
allowed: 7 orders for c-1000
$ TOKEN="$MINTED" python3 probe_gateway.py c-1001
denied by the gateway: Tool Execution Denied: Tool call not allowed due to
policy enforcement [Policy evaluation denied due to deny_other_customers_orders]
$ TOKEN="$COGNITO" python3 probe_gateway.py c-1000
error (not a policy decision) for c-1000: Client error '403 Forbidden'
The second is Cedar refusing another customer's orders on the minted token's
customer_id, before the tool runs. The third is the customer's raw Cognito
token being refused outright, the gateway does not trust the customer pool.
A token with a signature character altered, or with customer_id edited to
c-1001 under the original signature, gets the same 403. At the front door a
wrong client secret, a widened scope, a different audience or an unsigned
subject token are each refused before anything reaches the exchange pool.
And at the exchange pool itself, with the front door bypassed, custom auth
without the client secret is refused, password and SRP flows are refused, a
bogus answer is refused, a valid answer with a different token in the client
metadata is refused, and a valid answer with no metadata is refused. The
refresh and ID-token probes identify two controls that matter. Cognito processed a
REFRESH_TOKEN_AUTH attempt on the orders client even though its only
enabled flow is custom auth, and it was the pre-token trigger's refusal
that stopped a token coming out, so the trigger is the control there, not
the client's flow list. And the gateway's authorizer accepted the exchange
pool's ID token, because its aud is the same app client. The policy
evaluation reported the forbid, an ID token having no customer_id; the
permit would not have applied either, so default deny would have refused
it, and the forbid's real purpose is to keep that invariant against a
broader permit added later.
6 - What limits the token's use at another service
The runtime's network is public, so the agent can send a request anywhere. The question a security review asks is what the agent holds that another service would accept, and the answer is that the minted token is accepted by any verifier configured to trust the exchange pool and the orders client's audience, which in this demo is the order gateway alone, subject to Cedar. It is worth walking the reasons, because each is a different control and none of them is the agent's code.
The token names its audience, and the order gateway is the only downstream
in this demo configured to accept it. The exchange pool's pre-token trigger
writes the orders client's id as aud, and the gateway's JWT authorizer is
configured with that exact audience against the exchange pool's discovery
document, as section 4 showed. Isolation between downstreams is a distinct
app client per service and every verifier enforcing its own audience; a
payments gateway configured for a second client refuses this token before
any policy runs. That is the audience restriction the AWS sample's guidance
asks for. It limits
where the minted token can be replayed once it leaves the agent. The Cognito
access token has no aud, so a resource trusting the same pool and app
client has no audience check with which to tell a token meant for it from
one meant for something else. It does not shrink what a compromised agent
here could attempt, because the agent also holds the Cognito token.
The agent cannot ask for a different audience. The front door fixes the
audience and the scope in its configuration and refuses a request that names
any other, invalid_target and invalid_scope, which the tests cover and
the live probes confirmed. Nothing the agent puts in the request widens what
comes back.
Nobody can mint without the exchange pool's triggers. The front door holds no key. The exchange pool's app client is confidential, so the custom flow cannot be run against Cognito without its secret. Among the deployed execution roles only the front door can read the Secrets Manager copy, although Terraform state operators and principals allowed to describe the app client can also obtain it. Even with that secret, the triggers refuse anything that is not a valid customer token presented the same way twice.
Through AgentCore Identity, the runtime role can use only this one
provider. Its GetResourceOauth2Token is allowed on the exact ARN of the
demos_agentcore_05_obo credential provider and the one workload identity,
nothing else in the token vault. If the account held a second provider for a
payments issuer, this agent's role could not obtain a token from it. That
boundary is IAM, and it holds whatever the agent's code does.
There is no route to the order service that bypasses the gateway. The
runtime role has no lambda:InvokeFunction; only the gateway's role may
invoke the orders Lambda. The agent's only path to an order is through the
authorizer and then Cedar.
The gateway and the order tool do not accept the customer's Cognito token as their bearer credential. The gateway does not trust the customer pool, so relaying it is refused outright, as the probe in section 5 shows. The runtime validates that token for invocation, and the exchange accepts it as the subject token when the client also authenticates; no protected downstream resource in this stack accepts it directly. The agent does hold it, because the runtime forwards it, so anything outside this stack that trusts the customer pool and app client would accept it. The design's answer is that no downstream resource should, only the exchange.
And the minted token lives for five minutes. A token that leaks is usable for that long, at any verifier configured to trust this pool and audience, which in this demo is the order gateway.
Be equally precise about what this does not buy. Cedar prevents a
model-chosen or accidental customer_id from mismatching the token that was
presented. It does not prove the agent used the current invocation's token.
Cedar binds customer_id to the claim in whichever valid minted token
arrives, so a compromised agent serving many customers could reuse another
customer's token it had seen earlier and be permitted for that customer. It
cannot invent a customer it holds no valid token for. Stronger isolation
means performing the exchange in a trusted per-request component outside
the agent's code, or isolating workloads per tenant. It is still a bearer
token, so a stolen minted token is usable until it expires, and a stolen
Cognito token can still call the runtime and cause fresh exchanges until it
expires. The scope claim is carried, not enforced, because the Cedar
policy checks the customer and not the scope; the audience is what the
gateway enforces. And Cedar constrains the call, not the rows. It refuses a
list_orders whose requested customer differs from the token, but it cannot
see what the Lambda returns, so the order service must still select only
that customer's rows. Here it does, and a test proves it, but that is code
again, and where the data layer can enforce the tenant key it should.
For the orders tool the design implements the Well-Architected lens on tool authorisation, the gateway and its policy engine authorising every invocation against a declarative policy before the Lambda runs, and it propagates the customer's identity to that enforcement point as claims in a token minted for the purpose rather than giving the agent broad access.
https://docs.aws.amazon.com/wellarchitected/latest/agentic-ai-lens/agentsec02.html
Conclusion
The agent now acts for a known customer with a token brokered by AgentCore
Identity and minted for the purpose by a Cognito pool, with Cognito's keys,
the way AWS's own sample does it. Inbound auth establishes who the customer
is, the on-behalf-of exchange gives the agent a token for the order service
that is audience-restricted, five minutes long and not the customer's own
credential, carrying an orders/read scope claim this demo does not
enforce, and Policy in AgentCore refuses a call for anyone but the customer
named in that token before the tool runs. The gateway accepts no static
agent credential, the customer's token is refused there even if relayed by
mistake, and nothing mints without the exchange pool's triggers. What is not
bound is the token to the invocation; a compromised agent serving many
customers could present another customer's still-valid token and be
permitted for them. The memory and sandbox paths are still isolated in the
agent's own code, so moving those behind the same gateway and policy is how
you would finish the job, and in production the exchange belongs to a
managed IdP with a supported on-behalf-of integration rather than a pool
and a front door you run.
That is the precondition for the next post, where a model is handed the tools this series has built and chooses the arguments, including the customer id. Provided the model chooses the argument and not the token, which trusted code supplies, a wrong choice is refused at the gateway rather than read from someone else's account.
References:
- https://github.com/levantar-ai/demos/tree/main/agentcore/05-identity
- https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/on-behalf-of-token-exchange.html
- https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-oauth.html
- https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/policy-core-concepts.html
- https://docs.aws.amazon.com/cognito/latest/developerguide/token-endpoint.html
- https://github.com/aws-samples/sample-cognito-oauth2-token-exchange
- https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-pre-token-generation.html
- https://docs.aws.amazon.com/wellarchitected/latest/agentic-ai-lens/agentsec02.html
- https://docs.aws.amazon.com/wellarchitected/latest/agentic-ai-lens/agentsec03.html
- https://datatracker.ietf.org/doc/html/rfc8693