An AI agent that updates a ticket or reads a customer record acts for a person, inside applications that person can reach. Your identity team then has to answer a question that per-app OAuth consent and shared service accounts leave open: which agent might act for which user in which application, and how you stop it. Okta Cross App Access (XAA) answers it in your identity provider (IdP). You register the agent in Okta, an administrator approves the applications it might reach, and Okta evaluates policy each time the agent asks to act for a user. In our tests, after an administrator removed a user’s assignment to the agent, Okta refused the agent’s new requests for that user within about 1 second, and tokens already issued expired within 5 minutes.
Amazon Bedrock AgentCore is a platform to build, connect, and optimize agents at scale, with any framework or model. This solution uses two of its capabilities to carry out Okta’s decision on AWS. Amazon Bedrock AgentCore runtime, a capability of Amazon Bedrock AgentCore, validates Okta’s grant. Amazon Bedrock AgentCore Identity, a capability of Amazon Bedrock AgentCore, redeems it for a short-lived access token with credentials your agent code doesn’t see, and keeps that token in its token vault. In our tests, AgentCore Identity added about 0.6 seconds to a request that took about 30 seconds end to end.
In this post, the first of two, you build a task agent on AgentCore runtime that acts for signed-in users through XAA and AgentCore Identity. You configure the AgentCore Identity parts it needs, enforce each user’s authority in the tools, and see what we measured: what each check refuses, the practices we settled on, and how fast Okta’s controls take effect. Part 2 then puts the same governance behind Amazon Bedrock AgentCore Gateway, a capability of Amazon Bedrock AgentCore, with Cedar policies, a second agent on Amazon Elastic Kubernetes Service (Amazon EKS), and an audit trail in Amazon CloudWatch.
Solution overview
Okta is the governance plane: it decides which agent might act for which user, against which resource. AgentCore Identity is the execution plane: it holds the agent’s credentials, turns Okta’s decision into a token the resource accepts, and keeps both identities in that token. In Figure 1, the agent and the two Model Context Protocol (MCP) servers it calls all run on AgentCore runtime.
Each request carries three tokens, and each records a different decision: the ID token says who the user is, the Identity Assertion JWT Authorization Grant (ID-JAG) is Okta’s decision, and the access token is what the resource accepts. One request flows like this:
- The user signs in to the web front end, a backend for frontend (BFF), with OpenID Connect through the agent’s linked Okta app.
- For each request, the BFF exchanges the user’s ID token for an ID-JAG at Okta’s org authorization server, authenticated as the agent (RFC 8693 token exchange). Okta checks that the agent is active, that the user is assigned to it, and that an administrator connected it to the target authorization server for the requested scopes. The ID-JAG is single-use and expires in 5 minutes.
- The BFF invokes the agent on AgentCore runtime with the ID-JAG as its bearer token. The runtime’s JWT authorizer accepts only ID-JAGs that Okta issued to this agent.
- The runtime gets a workload access token from AgentCore Identity that binds the agent’s workload identity to the user, and passes it to the agent code. This workload access token is an AWS-internal binding token: neither Okta nor the resource sees it, so it isn’t one of the three tokens.
- The agent asks AgentCore Identity for an access token. AgentCore Identity redeems the ID-JAG at the resource’s Okta custom authorization server with the JWT authorization grant (RFC 7523), authenticated as the agent. Okta returns a 5-minute access token that names both the user and the agent and carries the user’s groups.
- The agent calls the MCP servers with that token. The task manager checks that a registered agent obtained it, and that the user’s group and scope allow the tool.
Figure 2 shows the same request as a sequence, with the check that each party makes.
Figure 2: The three-token chain and the check each party makes, with the step 4 workload access token staying inside AWS
Prerequisites
To follow along, you need the following:
| Prerequisite | What we used |
| An AWS account with access to Amazon Bedrock AgentCore and an Anthropic Claude model in Amazon Bedrock | US West (Oregon) |
| An Okta org with Cross App Access for AI agents and API Access Management (custom authorization servers) | An Okta Integrator Free Plan org |
| Python, the AWS Command Line Interface (AWS CLI), boto3, the AgentCore CLI, and the AgentCore Python SDK, a capability of Amazon Bedrock AgentCore | Python 3.11, AWS CLI version 2, boto3 1.43, AgentCore CLI 0.31.1, and AgentCore Python SDK 1.24.1 |
| AWS Identity and Access Management (IAM) permissions | Permission to create AgentCore resources and IAM roles |
The walkthrough takes about 1 hour. AgentCore charges for what you use. See Amazon Bedrock AgentCore pricing.
The base for this solution is the AWS sample 08-okta-xaa-delegation-chain in the AgentCore samples repository on GitHub, and you can build the solution in this post on top of it. Beyond that sample, the snippets and configurations in this post show the pattern conceptually. They aren’t a complete, deployable solution. The code is sample code for non-production use, so work with your security and legal teams to meet your organization’s security, regulatory, and compliance requirements before you deploy it.
Register the agent in Okta
Okta needs an authorization server for the resource, the agent, a connection between them, and a policy that admits the agent’s grant. Do all of these steps in the Okta Admin Console: registering the agent creates its linked app, so you don’t need an app integration wizard.
- Create a custom authorization server for the resource application with the audience
api://task-managerand the custom scopesxaa:readandxaa:write. Request only the resource’s custom scopes: Okta rejects an ID-JAG request that includes OpenID Connect scopes such asopenidorprofile(invalid_scope), so a request needs at least one custom scope and no OpenID Connect scopes. - Add a
groupsclaim to its access tokens, filtered to groups that start withxaa-. This claim carries each user’s authority to the tools. - In the Admin Console, go to Directory > AI Agents, choose Register AI agent and then Register manually, optionally assign an owner, and create a linked OpenID Connect app. On Client registration, choose Client secret, generate a secret, and then activate the agent. The agent and the app share one client ID, and that app must sign your users in: an ID token from another app fails the exchange with “the client application is not registered for delegation to this agent.”
- On the agent’s Resource connections tab, connect it to the authorization server, limited to the scopes it needs, as Figure 3 shows.
- Add an access policy rule to the custom authorization server that allows the agent’s JWT Bearer grant, with a 5-minute access token lifetime. Without the grant type, the second exchange fails with
no_matching_policy, and the lifetime bounds how long a revoked delegation keeps working. - Assign the users or groups who might act through the agent. They sign in through the linked app, so give it an app sign-in policy that admits them with the authenticators they’ve enrolled. In our sandbox org, the linked app had its own policy with a rule for the test group. For real users, apply your organization’s policy, including multifactor authentication.
Figure 3: The task agent’s resource connection in Okta, limited to the xaa:read and xaa:write scopes
Okta’s Cross App Access guide covers the console steps, and the AWS sample 08-okta-xaa-delegation-chain scripts the authorization server and its JWT bearer policy. The BFF then requests the ID-JAG, authenticated as the agent:
POST /oauth2/v1/token HTTP/1.1
Host: YOUR-ORG.okta.com
Authorization: Basic <the agent's client ID and secret>
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&requested_token_type=urn:ietf:params:oauth:token-type:id-jag
&subject_token=<the user's ID token>
&subject_token_type=urn:ietf:params:oauth:token-type:id_token
&audience=https://YOUR-ORG.okta.com/oauth2/YOUR-CUSTOM-AS-ID
&scope=xaa:read xaa:write
The AWS sample 06-okta-xaa sends the same request in Python (agent/xaa_client.py). The BFF authenticates as the agent, so store the agent’s client secret in AWS Secrets Manager rather than in code or configuration files.
Set up AgentCore Identity
AgentCore Identity holds three things for this solution, and your agent code doesn’t see the client secret or call Okta’s token endpoint:
| Part of AgentCore Identity | What it holds | Who creates it |
| Workload identity | The agent’s identity on AWS, bound to the signed-in user for each request | AgentCore runtime, for each agent runtime |
| OAuth2 credential provider | The agent’s Okta client ID and secret, and the grant to use | You, once per Okta agent |
| Token vault | The access tokens the provider obtains | AgentCore Identity |
Create the credential provider
The credential provider performs the second exchange. Point it at the custom authorization server, authenticate it as the AI agent, and set the on-behalf-of grant to JWT_AUTHORIZATION_GRANT, which AgentCore Identity sends as jwt-bearer with the ID-JAG as the assertion:
control = boto3.client("bedrock-agentcore-control")
provider = control.create_oauth2_credential_provider(
name="okta-xaa-obo",
credentialProviderVendor="CustomOauth2",
oauth2ProviderConfigInput={"customOauth2ProviderConfig": {
"oauthDiscovery": {"discoveryUrl": f"{OKTA_ORG}/oauth2/{CUSTOM_AS_ID}/.well-known/openid-configuration"},
"clientId": AGENT_CLIENT_ID, # the AI agent's Okta client ID
"clientSecret": AGENT_CLIENT_SECRET, # AgentCore Identity stores it in AWS Secrets Manager
"clientAuthenticationMethod": "CLIENT_SECRET_BASIC",
"onBehalfOfTokenExchangeConfig": {"grantType": "JWT_AUTHORIZATION_GRANT"},
}},
)
provider_arn = provider["credentialProviderArn"]
Okta binds both exchanges to the client named in the ID-JAG, so each Okta agent needs its own provider: when we redeemed one agent’s ID-JAG as another, Okta refused with “The ‘client_id’ in the JWT Bearer Grant must match the ‘client_id’ used to authenticate the client.” AgentCore Identity keeps the secret in a service-managed AWS Secrets Manager secret, which you rotate with update-oauth2-credential-provider. If your team already manages the client secret in Secrets Manager, you can reference that secret’s ARN instead. AgentCore Identity reads the client secret from it, so you can rotate the secret without updating the provider (see Reference your own AWS Secrets Manager secrets in Amazon Bedrock AgentCore Identity). To avoid a shared secret, authenticate with a private key instead. See Authenticate with Private Key JWT using Amazon Bedrock AgentCore Identity. Figure 4 shows the provider in the AgentCore console.
Use the workload identity that AgentCore runtime manages
AgentCore runtime creates and manages a workload identity for each agent runtime. When a request passes the runtime’s JWT authorizer, the runtime gets a workload access token for that workload identity and the inbound token’s issuer and subject, and passes it to your code. AgentCore Identity then uses the ID-JAG behind it as the assertion for the exchange.
The AgentCore Python SDK keeps the workload access token in BedrockAgentCoreContext, not in the request headers that your entrypoint receives. Our first version looked for it in the headers, found nothing, and fell back to its own GetWorkloadAccessTokenForJWT call with a second workload identity, on all 21 requests we checked. Read it from the context instead:
from bedrock_agentcore.runtime import BedrockAgentCoreContext
identity = boto3.client("bedrock-agentcore")
def okta_access_token() -> str:
workload_token = BedrockAgentCoreContext.get_workload_access_token() # minted by the runtime
return identity.get_resource_oauth2_token(
workloadIdentityToken=workload_token,
resourceCredentialProviderName="okta-xaa-obo",
scopes=["xaa:read", "xaa:write"], # must equal the ID-JAG's scopes
oauth2Flow="ON_BEHALF_OF_TOKEN_EXCHANGE",
)["accessToken"]
AgentCore Identity keeps the access token in its token vault for the rest of the request. Across 10 requests, the agent’s first call redeemed the ID-JAG in about half a second (see the latency table in Validate the results), and the second, for the other MCP server, returned the same token in a median of 58 milliseconds without redeeming the single-use ID-JAG again. A new ID-JAG for the same user got a new token, so each request reflects Okta’s latest decision. The sample 08-okta-xaa-delegation-chain reads the workload access token the same way (agent/agent.py).
Scope the agent’s role to its workload identity and provider
AgentCore Identity doesn’t bind workload identities to credential providers in the same account. The agent’s IAM role does, so scope it to the agent’s own workload identity and provider, and to that provider’s secret:
[
{
"Sid": "OktaOnBehalfOf",
"Effect": "Allow",
"Action": "bedrock-agentcore:GetResourceOauth2Token",
"Resource": [
"arn:aws:bedrock-agentcore:us-west-2:123456789012:workload-identity-directory/default",
"arn:aws:bedrock-agentcore:us-west-2:123456789012:workload-identity-directory/default/wor
