I tried out and deepened my understanding of the On-Behalf-Of (OBO) token exchange feature of Amazon Bedrock AgentCore Identity

I tried out and deepened my understanding of the On-Behalf-Of (OBO) token exchange feature of Amazon Bedrock AgentCore Identity

I actually tried the On-Behalf-Of (OBO) token exchange! It's quite difficult to understand...!!!!
2026.10.15

This page has been translated by machine translation. View original

Hello, I'm Kamino from the consulting department and I love Amazon Bedrock AgentCore.

I'm embarrassed to say that while I had been curious about AgentCore Identity's On-Behalf-Of (OBO) token exchange, I was in a state where "why do you even need it?" hadn't quite clicked for me...

https://aws.amazon.com/about-aws/whats-new/2026/04/amazon-bedrock-agentcore/

But the day finally came when I needed to consider whether to actually use it, and I decided to write a blog post to organize what it is!
I'll be going through this using Entra ID as an example!

Why OBO Is Needed

Why is this feature necessary? Let's start by organizing its significance!

Entra ID Tokens Have a Destination Written in Them

Let me review Entra ID tokens. When a user signs in with Entra ID, an access token is issued. The app receives this token and checks who the request is from.

Inside the token, it contains who (name), which app it's for (aud), and what is permitted (scp).
Entra ID issues tokens for each app that will use the token, so if it's being used for agent authentication, the token destination will be the agent's app.

Diagram showing that when a user signs in with Entra ID, an access token is issued with name, aud, and scp written in it

The receiving app should verify that the destination is itself before using it, and Microsoft's documentation also states that Web APIs should only accept tokens where aud is themselves. Any token won't do.

https://learn.microsoft.com/ja-jp/entra/identity-platform/access-tokens

Now let's consider a case where an AI agent authenticated with Entra wants to use Microsoft Graph.

The Token You Received Is Addressed to the Agent

In an agent for internal use, users attach a token addressed to this agent and make a request, but when this agent tries to call Microsoft Graph, passing the same token as-is will be rejected.

Diagram showing that passing the agent-addressed token directly to Graph results in a 401 rejection

When you try to call Graph's /me with a token addressed to the agent, you get an error like this.

Execution result
{"error":{"code":"InvalidAuthenticationToken","message":"Access token validation failure. Invalid audience.", ...}}
HTTP 401

"This token is not addressed to me" — it's being rejected.
Indeed, if it accepted tokens without checking the destination, tokens issued for other services could be reused, so rejecting them feels right.

So what's the best approach?

Problems with Other Approaches

Let me list the approaches I can think of for how the agent should call the Graph API.

Diagram comparing the three approaches: agent's own permissions, re-login per service, and OBO

If you call using the agent's own permissions (application permissions), the operator seen from Graph becomes the agent.
Since who made the request is not conveyed to Graph, you'd have to build logic on the agent side to filter data per user, and since permissions are attached to the app, depending on what permissions are granted, it could read data for everyone — so if you want per-user control, you'd want to avoid this.

With the method of having users log in and consent per service (3LO), you can call with the person's own permissions. However, having to re-consent when you're already authenticated with the same Entra ID feels a bit off...

That's where OBO comes in — it's a feature that passes the sign-in token to Entra ID and asks "I want to call Graph on behalf of this person," exchanging it for a token addressed to Graph. AgentCore Identity plays an active role in that exchange.

Microsoft's OBO flow documentation also explains it as a mechanism for passing along the user's identity and permissions down the chain of calls.

https://learn.microsoft.com/ja-jp/entra/identity-platform/v2-oauth2-on-behalf-of-flow

Couldn't You Just Pass a Graph-Addressed Token from the Start?

At this point, I simply thought "Why not skip OBO, and just have the user get a Graph-addressed token from the start and pass it to the agent?"

However, I think this would conflict with the mechanism for checking tokens at the agent's entry point (inbound authentication). AgentCore Runtime's inbound authentication checks the issuer and destination of the arriving token against the Runtime's settings, and rejects anything that doesn't match before it reaches the agent's code. The Runtime in this case is configured to only accept tokens whose destination is the agent's app ID, so Graph-addressed tokens are not expected.

https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/inbound-jwt-authorizer.html

Diagram showing that calling the agent with a Graph-addressed token is rejected by Runtime's inbound authentication

When I actually called Runtime with a Graph-addressed token, it was rejected with a 401.

Execution result
{"jsonrpc":"2.0","error":{"code":-32001,"message":"Claim 'iss' value mismatch with configuration."}}
HTTP 401

Then, what about configuring the Runtime side to also accept Graph-addressed tokens? Looking at Microsoft's documentation, that doesn't seem recommended. Graph-addressed tokens are in a proprietary format, so you can't validate them in your own app. Also, accepting tokens addressed to other resources is cited as an example of the "confused deputy" problem. The OBO documentation also states not to accept Graph-addressed tokens because they can't be used for OBO.

It seems best for the agent to only accept tokens addressed to itself, and when calling Graph, exchange them via OBO.

MCP Servers Don't Pass Tokens Directly

The same thinking applies when providing tools as an MCP server. In the MCP authorization specification (2025-11-25 version), an MCP server accepts tokens issued for itself, and when calling upstream APIs, it doesn't pass the received token as-is. For upstream APIs, a separately issued token for that API is used.

https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization

The security best practices list this forwarding as "Token passthrough" — a pattern to avoid. Risks listed include bypassing access controls and losing traceability of whose operation it was.

https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices

With a token exchanged via OBO, it's a separate token issued for the upstream API (Graph in this case), so this approach aligns with that thinking as well.

Now that I understand the mechanism, let's see how AgentCore Identity makes this easier!

What AgentCore Identity Can Handle

The OBO exchange itself can be done by asking Entra ID's token endpoint, but writing it yourself would look something like this. The parameters follow Microsoft's OBO flow documentation. Looks a bit tedious.

Doing OBO yourself (conceptual image)
res = httpx.post(
    f"https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token",
    data={
        "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
        "client_id": CLIENT_ID,
        "client_secret": CLIENT_SECRET,  # Manage and load the secret from somewhere
        "assertion": user_token,          # Pass the received user token as-is
        "scope": "https://graph.microsoft.com/User.Read",
        "requested_token_use": "on_behalf_of",
    },
)
graph_token = res.json()["access_token"]

With AgentCore Identity, the agent code just needs to say "give me the OBO token for this provider" — that's all. Just add a decorator!

Using AgentCore Identity
@requires_access_token(
    provider_name="entra-obo-provider",
    auth_flow="ON_BEHALF_OF_TOKEN_EXCHANGE",
    scopes=["https://graph.microsoft.com/User.Read"],
)
async def call_graph_me(*, access_token: str) -> dict:
    ...  # access_token is already exchanged for Graph

The secret stays stored in the Token Vault, and there's no code to pass the user's token around. Microsoft-specific parameters like requested_token_use are added by the Microsoft provider, making implementation easy.

https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/on-behalf-of-token-exchange.html

With AgentCore Gateway, you don't need to write any code at all. Just set grantType to TOKEN_EXCHANGE in the target's outbound authentication (authentication when calling the target).

Gateway target configuration (excerpt)
"oauthCredentialProvider": {
  "providerArn": "arn:aws:bedrock-agentcore:...:oauth2credentialprovider/entra-obo-provider",
  "scopes": ["https://graph.microsoft.com/User.Read"],
  "grantType": "TOKEN_EXCHANGE"
}

https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway-outbound-auth.html

Configuration for This Time

I'll try OBO with both Runtime and Gateway!
Here's a quick image of what I'll be doing.

Runtime Pattern

The user signs in with az CLI, and calls the agent on AgentCore Runtime with the obtained token attached. The agent exchanges it for a Graph token using AgentCore Identity, then calls /me with that token.

Architecture diagram showing User, Entra ID, AgentCore Runtime, AgentCore Identity, and Microsoft Graph

Gateway Pattern

The MCP client calls the AgentCore Gateway tool with the user's token attached. The Gateway checks the token with inbound authentication, then exchanges it for a Graph token using AgentCore Identity before calling the target.

Architecture diagram showing MCP client, Entra ID, AgentCore Gateway, AgentCore Identity, and Microsoft Graph

Let's give it a try!

Let's Try It

Prerequisites

You need an account that can register apps in Entra ID and grant admin consent to Graph permissions.

Item Version
Azure CLI 2.85.0
AWS CLI 2.37.13
AgentCore CLI 0.30.0
Node.js 26.7.0
uv 0.9.26
Python (Runtime) 3.14
bedrock-agentcore / strands-agents / httpx 1.24.1 / 1.59.0 / 0.28.1

Create an App for the Agent in Entra ID

Let's set things up from the "App registrations" section in the Azure portal. There are five things to configure.

  • Register the app
  • Expose the scope access_as_user (permission for users to use the agent)
  • Make Azure CLI an authorized client (to get tokens without a consent screen)
  • Grant admin consent to Microsoft Graph's User.Read (delegated) (the permission for the OBO exchange destination)
  • Set the access token version to 2 and create a client secret

Register the App

In "App registrations," click "New registration."

Click New registration in App registrations

Enter a name, leave the account type as "Single tenant only," and register. I used agentcore-obo-agent-portal this time.

Enter a name and register leaving it as single tenant

Once created, note down the client ID and tenant ID from the overview screen. These will be used in subsequent commands.

Note down the client ID and tenant ID of the created app

Store the noted values in variables
APP_ID=<Client ID>
TENANT_ID=<Tenant ID>

Expose the Scope

When you click "Add a Scope" under "Expose an API," you'll first be asked to set the Application ID URI. Save it as-is with the default api://<Client ID>.

Save the Application ID URI as the default value when adding a scope

Enter access_as_user as the scope name, select "Admins and users" for "Who can consent?" Add a display name and description that are easy to understand.

Enter scope name access_as_user, select Admins and users, and add it

Authorize Azure CLI

From "Add a client application" on the same "Expose an API" screen, enter the Azure CLI client ID 04b07795-8ddb-461a-bbee-02f9e1bf7b46, check the scope, and add it. This allows you to get tokens addressed to this app from az CLI without a consent screen.

Enter the Azure CLI client ID and check the scope

I used az CLI as the client for convenience this time, but in a real app, a web app or chat screen frontend would be the client. In that case, register a separate app for the frontend, and sign in using MSAL specifying api://<agent's client ID>/access_as_user. If you don't want a consent screen, authorize the frontend's client ID here instead of Azure CLI.

The frontend just passes this token to the agent, and the exchange to Graph is done on the agent side (OBO). Microsoft's OBO documentation also guides SPAs to pass tokens to a middle-tier API to do the OBO.

The scope exposure and authorized client settings are summarized in Microsoft's quickstart.

https://learn.microsoft.com/ja-jp/entra/identity-platform/quickstart-configure-app-expose-web-apis

When you open "API permissions," you'll find that a newly registered app already has Microsoft Graph's User.Read (delegated) in it. Click "Grant admin consent" and you're good once the status shows as granted. This permission will be used when getting a Graph-addressed token via OBO.

Grant admin consent to User.Read and the status becomes granted

Granting consent in advance is necessary because the OBO exchange happens between the agent and Entra ID, and there's no way to show the user a consent screen at that moment.

You might feel a bit uneasy thinking "Is it okay to grant consent for everyone in the organization?" but what you're allowing here is the delegated permission for "this app may use User.Read (reading the person's own profile) on behalf of the signed-in user." What the app can do as a proxy is limited to what the person themselves can do, so it doesn't grant the ability to read other people's data.

https://learn.microsoft.com/ja-jp/entra/identity-platform/permissions-consent-overview

Set the Token Version to 2

In "Manifest," change requestedAccessTokenVersion to 2 and save. This follows the Entra ID configuration procedure in AgentCore documentation.

https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/identity-idp-microsoft.html

Change requestedAccessTokenVersion to 2 in the manifest and save

Create a Client Secret

Under "Certificates & secrets," click "New client secret," enter a description, and add it. The value is displayed only once after adding, so note it down. AgentCore Identity will use it for the OBO exchange.

Enter a description for the client secret and add it

That's all the work in the portal. The same settings can be created with Azure CLI as well.

When creating with Azure CLI (setup_entra.sh)
setup_entra.sh
set -e
TENANT_ID=$(az account show --query tenantId -o tsv)
APP_ID=$(az ad app create --display-name agentcore-obo-agent \
  --sign-in-audience AzureADMyOrg --query appId -o tsv)
OBJ_ID=$(az ad app show --id "${APP_ID}" --query id -o tsv)
SCOPE_ID=$(uuidgen | tr A-Z a-z)

# Expose scope access_as_user and set access token version to 2
az ad app update --id "${APP_ID}" --identifier-uris "api://${APP_ID}"
az rest --method PATCH \
  --url "https://graph.microsoft.com/v1.0/applications/${OBJ_ID}" \
  --headers Content-Type=application/json --body "{
  \"api\": {
    \"requestedAccessTokenVersion\": 2,
    \"oauth2PermissionScopes\": [{
      \"id\": \"${SCOPE_ID}\", \"value\": \"access_as_user\", \"type\": \"User\", \"isEnabled\": true,
      \"adminConsentDisplayName\": \"Access agent as user\",
      \"adminConsentDescription\": \"Access agent as user\"
    }]
  }
}"

# Pre-authorize Azure CLI (04b07795-...)
az rest --method PATCH \
  --url "https://graph.microsoft.com/v1.0/applications/${OBJ_ID}" \
  --headers Content-Type=application/json --body "{
  \"api\": { \"preAuthorizedApplications\": [{
    \"appId\": \"04b07795-8ddb-461a-bbee-02f9e1bf7b46\",
    \"delegatedPermissionIds\": [\"${SCOPE_ID}\"]
  }] }
}"

# Add Microsoft Graph User.Read (delegated) and grant tenant-wide consent
az ad app permission add --id "${APP_ID}" \
  --api 00000003-0000-0000-c000-000000000000 \
  --api-permissions e1fe6dd8-ba31-4d61-89e7-88639da4683d=Scope
az ad sp create --id "${APP_ID}"
az ad app permission grant --id "${APP_ID}" \
  --api 00000003-0000-0000-c000-000000000000 --scope User.Read

echo "TENANT_ID=${TENANT_ID}" > .env.ids
echo "APP_ID=${APP_ID}" >> .env.ids
Create the app
bash setup_entra.sh
source .env.ids

The client secret can be issued with az ad app credential reset when creating the provider in the next section.

Try Getting the User's Token

Get a token addressed to the agent app using az CLI.

Get the user's token
USER_TOKEN=$(az account get-access-token \
  --scope "api://${APP_ID}/access_as_user" --query accessToken -o tsv)

When you decode the contents (payload), the destination shows as the agent's app ID.

decode.py (displays JWT payload)
decode.py
import sys, base64, json
keys = sys.argv[1:]
p = sys.stdin.read().strip().split(".")[1]
p += "=" * (-len(p) % 4)
c = json.loads(base64.urlsafe_b64decode(p))
print(json.dumps({k: c.get(k) for k in keys} if keys else c, ensure_ascii=False, indent=1))
Check the claims
echo "${USER_TOKEN}" | uv run decode.py aud iss scp azp name
Execution result
{
 "aud": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
 "iss": "https://login.microsoftonline.com/yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy/v2.0",
 "scp": "access_as_user",
 "azp": "04b07795-8ddb-461a-bbee-02f9e1bf7b46",
 "name": "Kamino Yudai"
}

The 401 at the beginning was the result of sending this token directly to Graph.

Send it directly to Graph
curl -s -w "\nHTTP %{http_code}\n" https://graph.microsoft.com/v1.0/me \
  -H "Authorization: Bearer ${USER_TOKEN}"

Now let's proceed with the AgentCore Identity configuration!

Register a Microsoft Provider in AgentCore Identity

Register the noted client secret in an AgentCore Identity OAuth provider.

Create the provider
read -s SECRET   # Paste the secret value noted in the portal

aws bedrock-agentcore-control create-oauth2-credential-provider \
  --name entra-obo-provider \
  --credential-provider-vendor MicrosoftOauth2 \
  --oauth2-provider-config-input "{\"microsoftOauth2ProviderConfig\":{
    \"clientId\":\"${APP_ID}\",\"clientSecret\":\"${SECRET}\",\"tenantId\":\"${TENANT_ID}\"}}"
unset SECRET
When issuing a secret with Azure CLI
Issue a secret
SECRET=$(az ad app credential reset --id "${APP_ID}" \
  --display-name agentcore-obo --years 1 --query password -o tsv)

After this, run the create-oauth2-credential-provider command above.

The OBO-specific configuration item (onBehalfOfTokenExchangeConfig) is for custom providers — the Microsoft provider doesn't have this item, so you specify the client ID, secret, and tenant ID.

From here, I'll try both the Runtime pattern and the Gateway pattern!

Trying the Runtime Pattern

Create the Agent

Create a project with AgentCore CLI and add an agent that accepts Entra ID tokens. The inbound authentication conditions are the discovery URL and the destination (agent's app ID).

Create the project and agent
agentcore create --project-name OboDemo --no-agent --skip-git
cd OboDemo
agentcore add agent --name obo_agent --type create --language Python \
  --framework Strands --model-provider Bedrock --memory none \
  --authorizer-type CUSTOM_JWT \
  --discovery-url "https://login.microsoftonline.com/${TENANT_ID}/v2.0/.well-known/openid-configuration" \
  --allowed-audience "${APP_ID}"

Rewrite the generated app/obo_agent/main.py to have a tool that calls Graph. For the OBO configuration, specify ON_BEHALF_OF_TOKEN_EXCHANGE with requires_access_token.

app/obo_agent/main.py (OBO part)
@requires_access_token(
    provider_name="entra-obo-provider",
    auth_flow="ON_BEHALF_OF_TOKEN_EXCHANGE",
    scopes=["https://graph.microsoft.com/User.Read"],
)
async def call_graph_me(*, access_token: str) -> dict:
    ...  # access_token is already exchanged for Graph

As mentioned earlier, there's no code to pass the user's token to the function!

After the Runtime validates the JWT, it passes a workload token containing user information to the agent, and the SDK uses that to request the exchange.

app/obo_agent/main.py (full)
app/obo_agent/main.py
import httpx
from strands import Agent, tool
from bedrock_agentcore.runtime import BedrockAgentCoreApp
from bedrock_agentcore.identity.auth import requires_access_token

app = BedrockAgentCoreApp()

# Exchange the user's token for a Graph token (OBO) before calling
@requires_access_token(
    provider_name="entra-obo-provider",
    auth_flow="ON_BEHALF_OF_TOKEN_EXCHANGE",
    scopes=["https://graph.microsoft.com/User.Read"],
)
async def call_graph_me(*, access_token: str) -> dict:
    async with httpx.AsyncClient() as client:
        res = await client.get(
            "https://graph.microsoft.com/v1.0/me",
            params={"$select": "displayName,userPrincipalName"},
            headers={"Authorization": f"Bearer {access_token}"},
        )
        return {"status": res.status_code, "body": res.json()}

@tool
async def get_my_profile() -> dict:
    """Get the signed-in user's own profile from Microsoft Graph"""
    return await call_graph_me()

@app.entrypoint
async def invoke(payload, context):
    # Create Agent per request to prevent conversation history from mixing across users
    agent = Agent(
        model="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
        system_prompt="You are an internal assistant. Please check user-specific information using tools.",
        tools=[get_my_profile],
    )
    result = await agent.invoke_async(payload.get("prompt", ""))
    return {"result": str(result)}

if __name__ == "__main__":
    app.run()

The MCP client and other things in the generated template aren't needed, so I deleted mcp_client/, skills/, and model/. I also tidied up the dependencies in pyproject.toml accordingly.

app/obo_agent/pyproject.toml
app/obo_agent/pyproject.toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "obo_agent"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = [
    "aws-opentelemetry-distro ~= 0.18.0",
    "bedrock-agentcore >= 1.24.1",
    "strands-agents >= 1.59.0",
    "httpx >= 0.28.0",
]

[tool.hatch.build.targets.wheel]
packages = ["."]
Update dependencies
cd app/obo_agent && uv lock && cd ../..

Deploy and Add Permissions

Write the destination account and region, then deploy.

Deploy
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
echo "[{\"name\":\"default\",\"account\":\"${ACCOUNT_ID}\",\"region\":\"us-east-1\"}]" \
  > agentcore/aws-targets.json
agentcore deploy -y

The Runtime execution role created by the CLI didn't include permissions to use the provider I created outside the CLI. I'll add permissions to get tokens and read the provider's secret.

Add permissions to the execution role
ROLE_NAME=$(aws cloudformation describe-stacks --stack-name AgentCore-OboDemo-default \
  --query "Stacks[0].Outputs[?contains(OutputKey,'RoleArn')].OutputValue" --output text | cut -d/ -f2)
SECRET_ARN=$(aws bedrock-agentcore-control get-oauth2-credential-provider \
  --name entra-obo-provider --query clientSecretArn.secretArn --output text)
BASE="arn:aws:bedrock-agentcore:us-east-1:${ACCOUNT_ID}"

aws iam put-role-policy --role-name "${ROLE_NAME}" --policy-name entra-obo --policy-document "{
  \"Version\": \"2012-10-17\",
  \"Statement\": [
    {\"Effect\": \"Allow\", \"Action\": \"bedrock-agentcore:GetResourceOauth2Token\",
     \"Resource\": [
       \"${BASE}:token-vault/default\",
       \"${BASE}:token-vault/default/oauth2credentialprovider/entra-obo-provider\",
       \"${BASE}:workload-identity-directory/default\",
       \"${BASE}:workload-identity-directory/default/workload-identity/*\"]},
    {\"Effect\": \"Allow\", \"Action\": \"secretsmanager:GetSecretValue\", \"Resource\": \"${SECRET_ARN}\"}
  ]
}"

Let's Try Calling It

We'll call the Runtime with a user token obtained via the az CLI. The question is "Who am I?"

Call the Runtime
RUNTIME_ARN=$(aws cloudformation describe-stacks --stack-name AgentCore-OboDemo-default \
  --query "Stacks[0].Outputs[?contains(OutputKey,'RuntimeArn')].OutputValue" --output text)
ENCODED=$(uv run python -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1],safe=''))" "${RUNTIME_ARN}")
USER_TOKEN=$(az account get-access-token \
  --scope "api://${APP_ID}/access_as_user" --query accessToken -o tsv)

curl -s -X POST \
  "https://bedrock-agentcore.us-east-1.amazonaws.com/runtimes/${ENCODED}/invocations?qualifier=DEFAULT" \
  -H "Authorization: Bearer ${USER_TOKEN}" \
  -H "Content-Type: application/json" \
  -H "X-Amzn-Bedrock-AgentCore-Runtime-Session-Id: $(uuidgen)-$(uuidgen)" \
  -d '{"prompt": "私は誰ですか?"}'
Execution result
{"result": "You are **Kamino Yudai**.\n\nYour email address is registered as `user@example.onmicrosoft.com`.\n\nIs there anything I can help you with?\n"}

The person's name was returned from Graph! From the same user's token that resulted in a 401 at the beginning, the agent was able to call Graph on behalf of that person.

I also tested the "what happens if we pass a token intended for Graph" scenario from the previous section here. I obtained a token for Graph using the az CLI and made the same call.

Call the Runtime with a Graph token
GRAPH_TOKEN=$(az account get-access-token --resource-type ms-graph --query accessToken -o tsv)

curl -s -w "\nHTTP %{http_code}\n" -X POST \
  "https://bedrock-agentcore.us-east-1.amazonaws.com/runtimes/${ENCODED}/invocations?qualifier=DEFAULT" \
  -H "Authorization: Bearer ${GRAPH_TOKEN}" \
  -H "Content-Type: application/json" \
  -H "X-Amzn-Bedrock-AgentCore-Runtime-Session-Id: $(uuidgen)-$(uuidgen)" \
  -d '{"prompt": "私は誰ですか?"}'
Execution result
{"jsonrpc":"2.0","error":{"code":-32001,"message":"Claim 'iss' value mismatch with configuration."}}
HTTP 401

As expected, this also resulted in an error!

Comparing Tokens Before and After Exchange

Let's also look at what changed in the token after the exchange. I executed the same steps that the SDK performs inside the Runtime using the CLI. I create a workload token from the user's token and exchange it via OBO.

Execute OBO exchange via CLI
aws bedrock-agentcore-control create-workload-identity --name obo-local-test

WAT=$(aws bedrock-agentcore get-workload-access-token-for-jwt \
  --workload-name obo-local-test --user-token "${USER_TOKEN}" \
  --query workloadAccessToken --output text)

OBO_TOKEN=$(aws bedrock-agentcore get-resource-oauth2-token \
  --resource-credential-provider-name entra-obo-provider \
  --oauth2-flow ON_BEHALF_OF_TOKEN_EXCHANGE \
  --scopes "https://graph.microsoft.com/User.Read" \
  --workload-identity-token "${WAT}" --query accessToken --output text)

echo "${OBO_TOKEN}" | uv run decode.py aud scp appid app_displayname name
Execution result
{
 "aud": "https://graph.microsoft.com",
 "scp": "User.Read profile openid email",
 "appid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
 "app_displayname": "agentcore-obo-agent-portal",
 "name": "Kamino Yudai"
}

When placed side by side, it looks like this.

Diagram showing that name remains the same before and after exchange, while aud changes from the agent's app to Graph

The destination (aud) changed to Graph, and the permission (scp) became User.Read for Graph. On the other hand, the name (name) remains the person themselves, and which app is calling on their behalf (appid) is also included. From Graph's perspective, it can tell that "agentcore-obo-agent-portal is calling on behalf of Kamino."

Trying the Gateway Pattern

Next is the pattern where we delegate the exchange to the Gateway. We register the Graph API as an OpenAPI tool in the Gateway and set the outbound authentication for the target to OBO.

Creating the Gateway Role

We create a service role for the Gateway to perform token exchanges and read secrets.

Create the Gateway service role
Create the Gateway service role
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
BASE="arn:aws:bedrock-agentcore:us-east-1:${ACCOUNT_ID}"
SECRET_ARN=$(aws bedrock-agentcore-control get-oauth2-credential-provider \
  --name entra-obo-provider --query clientSecretArn.secretArn --output text)

aws iam create-role --role-name obo-gateway-role --assume-role-policy-document "{
  \"Version\": \"2012-10-17\",
  \"Statement\": [{\"Effect\": \"Allow\",
    \"Principal\": {\"Service\": \"bedrock-agentcore.amazonaws.com\"},
    \"Action\": \"sts:AssumeRole\",
    \"Condition\": {\"StringEquals\": {\"aws:SourceAccount\": \"${ACCOUNT_ID}\"}}}]
}"

aws iam put-role-policy --role-name obo-gateway-role --policy-name obo-gateway --policy-document "{
  \"Version\": \"2012-10-17\",
  \"Statement\": [
    {\"Effect\": \"Allow\",
     \"Action\": [\"bedrock-agentcore:GetWorkloadAccessToken\", \"bedrock-agentcore:GetWorkloadAccessTokenForJWT\"],
     \"Resource\": [\"${BASE}:workload-identity-directory/default\",
                    \"${BASE}:workload-identity-directory/default/workload-identity/obo-gateway-*\"]},
    {\"Effect\": \"Allow\", \"Action\": \"bedrock-agentcore:GetResourceOauth2Token\",
     \"Resource\": [\"${BASE}:token-vault/default\",
                    \"${BASE}:token-vault/default/oauth2credentialprovider/entra-obo-provider\",
                    \"${BASE}:workload-identity-directory/default\",
                    \"${BASE}:workload-identity-directory/default/workload-identity/obo-gateway-*\"]},
    {\"Effect\": \"Allow\", \"Action\": \"secretsmanager:GetSecretValue\", \"Resource\": \"${SECRET_ARN}\"}
  ]
}"

Creating the Gateway and Target

For inbound authentication, we use the same Entra ID discovery URL and destination (agent's app ID) as the Runtime.

Create the Gateway
GATEWAY_ID=$(aws bedrock-agentcore-control create-gateway --name obo-gateway \
  --role-arn "arn:aws:iam::${ACCOUNT_ID}:role/obo-gateway-role" \
  --protocol-type MCP --authorizer-type CUSTOM_JWT \
  --authorizer-configuration "{\"customJWTAuthorizer\":{
    \"discoveryUrl\":\"https://login.microsoftonline.com/${TENANT_ID}/v2.0/.well-known/openid-configuration\",
    \"allowedAudience\":[\"${APP_ID}\"]}}" \
  --query gatewayId --output text)

The target is an OpenAPI schema defining Graph's /me endpoint.

graph-me.json
graph-me.json
{
  "openapi": "3.0.1",
  "info": {"title": "Microsoft Graph (me)", "version": "1.0"},
  "servers": [{"url": "https://graph.microsoft.com/v1.0"}],
  "paths": {
    "/me": {
      "get": {
        "operationId": "getMyProfile",
        "description": "Get the profile of the currently signed-in user",
        "parameters": [{"name": "$select", "in": "query", "required": false, "schema": {"type": "string"}}],
        "responses": {"200": {"description": "User profile",
          "content": {"application/json": {"schema": {"type": "object"}}}}}
      }
    }
  }
}

The OBO configuration is specified in the target's outbound authentication by providing the provider and grantType: TOKEN_EXCHANGE.

Add a target that calls Graph via OBO
PROVIDER_ARN=$(aws bedrock-agentcore-control get-oauth2-credential-provider \
  --name entra-obo-provider --query credentialProviderArn --output text)
SCHEMA=$(uv run python -c "import json;print(json.dumps(open('graph-me.json').read()))")

aws bedrock-agentcore-control create-gateway-target --gateway-identifier "${GATEWAY_ID}" --name graph \
  --target-configuration "{\"mcp\":{\"openApiSchema\":{\"inlinePayload\":${SCHEMA}}}}" \
  --credential-provider-configurations "[{
    \"credentialProviderType\": \"OAUTH\",
    \"credentialProvider\": {\"oauthCredentialProvider\": {
      \"providerArn\": \"${PROVIDER_ARN}\",
      \"scopes\": [\"https://graph.microsoft.com/User.Read\"],
      \"grantType\": \"TOKEN_EXCHANGE\"}}}]"

Let's Try Calling the Gateway

Now that it's created, let's use curl in place of an MCP client to call tools/list and tools/call with the user's token.

Call the Gateway tools
GATEWAY_URL=$(aws bedrock-agentcore-control get-gateway --gateway-identifier "${GATEWAY_ID}" \
  --query gatewayUrl --output text)

curl -s -X POST "${GATEWAY_URL}" -H "Authorization: Bearer ${USER_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

curl -s -X POST "${GATEWAY_URL}" -H "Authorization: Bearer ${USER_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"graph___getMyProfile","arguments":{"$select":"displayName,userPrincipalName"}}}'
Execution result (excerpt from tools/call)
{"jsonrpc":"2.0","id":2,"result":{"isError":false,"content":[{"type":"text","text":"{...\"displayName\":\"Kamino Yudai\",\"userPrincipalName\":\"user@example.onmicrosoft.com\"}"}]}}

The tool name is graph___getMyProfile, and the person's name was returned from Graph! No token exchange code was written!

The Gateway verifies the user's token and exchanges it for a Graph token before calling the target.

Of course, calling the Gateway with a Graph token was also rejected at the entry point.

Execution result
{"jsonrpc":"2.0","id":0,"error":{"code":-32002,"message":"insufficient_scope - The request requires higher privileges than provided by the access token."}}
HTTP 403

Cleanup

Delete the Gateway, Runtime, provider, and the Entra ID app. The inline policy manually added to the Runtime's execution role should be removed before deleting the stack.

Delete the Gateway
TARGET_ID=$(aws bedrock-agentcore-control list-gateway-targets --gateway-identifier "${GATEWAY_ID}" \
  --query "items[0].targetId" --output text)
aws bedrock-agentcore-control delete-gateway-target --gateway-identifier "${GATEWAY_ID}" --target-id "${TARGET_ID}"
aws bedrock-agentcore-control delete-gateway --gateway-identifier "${GATEWAY_ID}"
aws iam delete-role-policy --role-name obo-gateway-role --policy-name obo-gateway
aws iam delete-role --role-name obo-gateway-role
Delete the Runtime and provider
aws iam delete-role-policy --role-name "${ROLE_NAME}" --policy-name entra-obo
aws cloudformation delete-stack --stack-name AgentCore-OboDemo-default
aws bedrock-agentcore-control delete-oauth2-credential-provider --name entra-obo-provider
aws bedrock-agentcore-control delete-workload-identity --name obo-local-test
Delete the Entra ID app
az ad app delete --id "${APP_ID}"

Closing

By actually trying it out, I hope you've gained a better understanding of OBO's significance! It seems particularly useful when you want to use Entra for inbound authentication and also want to use that token for outbound calls.

The tokens that can be submitted for OBO are tokens that Entra ID has issued for the agent. For configurations where sign-in is handled by a different IdP such as Cognito, a 3LO approach where the user grants consent to Microsoft would be a candidate.

I hope this article was helpful in some way. Thank you for reading to the end!

Share this article

AWSのお困り事はクラスメソッドへ