Authorizing AgentCore Gateway tool calls with PingOne Authorize and CIBA approvals
On this page
This guide sets up the demo's use case in your own PingOne environment and AWS account, in front of your own MCP servers. An AI agent on Amazon Bedrock AgentCore Runtime calls MCP tools through AgentCore Gateway. A Lambda interceptor sends every tool call to PingOne Authorize, and a call that needs the user's approval waits for a CIBA approval on the user's other device.
The guide follows the demo's own configuration. Names such as agent-obo and users:delete are examples: use your own, and keep them consistent between PingOne and AWS.
Goals
- Decide every MCP tool call with a PingOne Authorize policy that sees the user's permissions, the tool, and its arguments.
- Require the user's approval through CIBA for sensitive calls. The approval is tied to the exact request and the same user, and works once.
- Keep the user's identity on every hop with on-behalf-of token exchange (RFC 8693). Each MCP server receives a token minted for it alone that names the user and the agents acting for them.
- Tell the user why a call was refused, in the policy's own words.
How it works
- The user signs in.Your chat app signs the user in with PingOne. The sign-in token is valid only at the agent runtime and names the agent in
may_act. - The agent gets a Gateway token.AgentCore Identity exchanges the user's token for one valid only at the Gateway, with
actnaming the agent. - The interceptor asks PingOne Authorize.The Gateway runs the authorizer Lambda on every request. The Lambda sends the request to PingOne Authorize API Access Management, which applies your policy.
- A sensitive call waits for approval.When the policy asks for approval, the Lambda starts a PingOne CIBA request and answers the agent with JSON-RPC
-32004and a reference. The agent shows the approval code and replays the call with the reference until the user answers. - The approved call goes ahead once.The Lambda checks that the approval matches this request and user, records it as used in DynamoDB, and asks PingOne Authorize again with a trusted approval header that only the Lambda can set.
- The MCP server gets its own token.The Gateway exchanges the Gateway token for one valid only at that MCP server, with
actnaming the Gateway and the agent.
For how AgentCore Identity uses PingOne for inbound authentication and outbound credentials in general, see Securing Amazon Bedrock AgentCore Identity with Ping Identity. This guide builds on that pattern with token exchange, policy and approvals.
Before you begin
You need:
- A PingOne environment with PingOne SSO, DaVinci and PingOne Authorize (API Access Management), and an admin who can create applications, resources and policies.
- An AWS account with Amazon Bedrock AgentCore in your Region, and permission to create IAM roles, Lambda functions and DynamoDB tables.
- MCP servers reachable over HTTPS (streamable HTTP) that validate PingOne access tokens: issuer, signature, and an audience equal to the server's own URL. Tools that act on a person should take that person's username as an argument (the demo uses
user_name) so the policy can read it. - The authorizer Lambda package
bootstrap.zip, built from thep1az-ciba-authorizersource for arm64 withmake docker-packageorcargo lambda build --release --arm64 --bin bedrock. - An agent you can change to call AgentCore Identity and handle the Gateway's approval responses. The demo's Strands agent is the reference.
Values used in this guide
| Placeholder | What it is |
|---|---|
<ENV_ID> | Your PingOne environment ID. Examples use auth.pingone.com; use your Region's domain (.eu, .ca, .asia) if it differs. |
<ISSUER> | https://auth.pingone.com/<ENV_ID>/as |
<DISCOVERY_URL> | <ISSUER>/.well-known/openid-configuration |
<AGENT_AUD>, <GATEWAY_AUD> | Audiences you choose for the agent runtime and the Gateway, for example https://agent.example.com and https://mcp-gateway.example.com. The demo uses the runtime's and Gateway's own URLs. Any https URI works if PingOne and AWS use the same value. |
<MCP_URL> | Each MCP server's URL, which is also its audience |
<REGION>, <ACCOUNT_ID> | Your AWS Region and account |
1. Create the resources and claim mappings
Each token in the chain is minted for one resource. The claim mappings carry the user's identity from token to token and record who is acting.
- Sign on to the PingOne admin console and go to Applications > Resources.
- Create these custom resources. For each one, enter the audience, add the scopes, and on the agent runtime and Gateway resources turn on the application permissions claim so tokens carry
p1.permissions.Resource Audience Scopes Permissions claim agent-runtime <AGENT_AUD>agent:invokeOn gateway <GATEWAY_AUD>mcp:toolsOn actor an https URI of your choice, for example https://actor.example.comactorOff one per MCP server <MCP_URL>the scopes that server checks, for example crm:users.read,crm:users.write,crm:users.deleteOff - Add the attribute mappings below on each resource's Attributes tab. Replace the client IDs once you have created the applications in step 2.
Resource Attribute Value agent-runtime may_act${{'sub': '<AGENT_APP_ID>'}}gateway sub${#root.context.requestData.subjectToken.sub}gateway act(required)First-hop expression below gateway may_act${{'sub': '<GATEWAY_APP_ID>'}}gateway username${user.username}, used as the CIBAlogin_hinteach MCP server sub${#root.context.requestData.subjectToken.sub}each MCP server act(required)Second-hop expression below gateway:act(first hop)${(#root.context.requestData.grantType == 'client_credentials') ? 'noActor' : ((#root.context.requestData.subjectToken.may_act.sub == #root.context.requestData.actorToken.client_id) ? #root.context.requestData.subjectToken.may_act : null)}each MCP server:act(second hop)${(#root.context.requestData.grantType == 'client_credentials') ? 'noActor' : ((#root.context.requestData.subjectToken.may_act.sub == #root.context.requestData.actorToken.client_id) ? {'sub': #root.context.requestData.actorToken.client_id, 'act': #root.context.requestData.subjectToken.act} : null)} - Mark both
actmappings as required ("required": trueon the resource attribute). A required claim whose expression returnsnullmakes PingOne refuse the token, so an exchange by an actor the subject token didn't name inmay_actfails instead of producing a token withoutact.
2. Create the applications
- Go to Applications > Applications and create the four applications below. Use Client Secret Basic as the token endpoint authentication method on all of them except the chat app, which can use whatever your frontend needs.
Application Type and grants Resource access Chat app OIDC Web App: Authorization Code, PKCE required (S256), your redirect URI agent-runtime: agent:invoke, plusopenidAgent AI Agent (or a machine-to-machine service app): Client Credentials and Token Exchange gateway: mcp:tools; actor:actorGateway exchange Machine-to-machine service app: Client Credentials and Token Exchange each MCP server: all its scopes; actor: actorApprovals OIDC Web App with the CIBA grant openid - Copy each application's client ID into the
may_actmappings from step 1: the agent's ID on agent-runtime, the Gateway exchange app's ID on gateway. - Keep the client secrets of the agent, Gateway exchange and approvals apps. AWS needs them in steps 5 and 10.
3. Define the permissions and roles
The policy decides with the user's application permissions, which reach the Gateway token as p1.permissions.
- On the agent-runtime and gateway resources, add an application resource named
userswith the permissionsread,writeanddelete. Tokens list them asusers:read,users:writeandusers:delete. - Create application roles and give each the permissions it allows. The demo uses:
- help-desk:
read,write - iam-admin:
read,write,delete
- help-desk:
- Assign the roles to your users. Each user who can approve needs a username (sent as the CIBA
login_hint) and an email address or device that receives the approval.
4. Set up CIBA approvals
- In DaVinci, create a flow that a CIBA request triggers and that lets the user approve or decline. The demo's flow looks the user up by username and emails an approve and decline link that shows the binding message. Its export is in the demo repository under
infra/pingone/flow/. - Assign the flow to the approvals app with a DaVinci flow policy.
- Check the CIBA limits you'll configure in step 10: PingOne accepts a binding message of up to 8 characters (the authorizer sends
APR-and four digits) and a requested expiry of up to 300 seconds.
The authorizer also supports PingFederate as the CIBA provider. PingFederate 11.2 or later binds the approval to the request with authorization_details instead of the DynamoDB record.
5. Create the AgentCore Identity credential providers
Two OAuth credential providers do the two token exchanges. Both use PingOne's discovery document and get an actor token with client credentials for the actor scope.
- Create
agent-obowith the agent app's credentials:agent-obo.json{ "name": "agent-obo", "credentialProviderVendor": "CustomOauth2", "oauth2ProviderConfigInput": { "customOauth2ProviderConfig": { "oauthDiscovery": { "discoveryUrl": "<DISCOVERY_URL>" }, "clientId": "<AGENT_APP_ID>", "clientSecret": "<AGENT_APP_SECRET>", "clientAuthenticationMethod": "CLIENT_SECRET_BASIC", "onBehalfOfTokenExchangeConfig": { "grantType": "TOKEN_EXCHANGE", "tokenExchangeGrantTypeConfig": { "actorTokenContent": "M2M", "actorTokenScopes": ["actor"] } } } } }Create itaws bedrock-agentcore-control create-oauth2-credential-provider --cli-input-json file://agent-obo.json - Create
gateway-obothe same way with the Gateway exchange app's client ID and secret.
6. Create the Gateway and its targets
- Create the Gateway's IAM role, trusted by
bedrock-agentcore.amazonaws.com. It needsbedrock-agentcore:*for the credential providers,secretsmanager:GetSecretValueonarn:aws:secretsmanager:<REGION>:<ACCOUNT_ID>:secret:bedrock-agentcore-identity!*, and laterlambda:InvokeFunctionon the authorizer (step 10). - Create the Gateway with a PingOne JWT authorizer. It accepts any token whose scopes include
mcp:tools.gateway.json{ "name": "mcp-gateway", "roleArn": "arn:aws:iam::<ACCOUNT_ID>:role/mcp-gateway", "protocolType": "MCP", "authorizerType": "CUSTOM_JWT", "authorizerConfiguration": { "customJWTAuthorizer": { "discoveryUrl": "<DISCOVERY_URL>", "allowedAudience": ["<GATEWAY_AUD>"], "allowedScopes": ["mcp:tools"] } } }Note theCreate itaws bedrock-agentcore-control create-gateway --cli-input-json file://gateway.jsongatewayUrlin the response (https://<GATEWAY_ID>.gateway.bedrock-agentcore.<REGION>.amazonaws.com/mcp). You need it in step 8. - Add one target per MCP server. The target exchanges the Gateway token through
gateway-obofor the server's own token. Allow thex-paz-response-headersheader, which carries a policy denial's reason from the request phase to the response phase.target-crm.json{ "gatewayIdentifier": "<GATEWAY_ID>", "name": "crm", "targetConfiguration": { "mcp": { "mcpServer": { "endpoint": "<MCP_URL>", "listingMode": "DYNAMIC" } } }, "credentialProviderConfigurations": [{ "credentialProviderType": "OAUTH", "credentialProvider": { "oauthCredentialProvider": { "providerArn": "<GATEWAY_OBO_ARN>", "scopes": ["crm:users.read", "crm:users.write", "crm:users.delete"], "grantType": "TOKEN_EXCHANGE", "customParameters": { "subject_token_type": "urn:ietf:params:oauth:token-type:access_token" } } } }], "metadataConfiguration": { "allowedRequestHeaders": ["x-paz-response-headers"] } }Create itaws bedrock-agentcore-control create-gateway-target --cli-input-json file://target-crm.json
Always send the subject_token_type override. AgentCore's default token type is the generic JWT type, which PingOne rejects.
7. Deploy the agent on AgentCore Runtime
- Create a workload identity for the agent:
Create it
aws bedrock-agentcore-control create-workload-identity --name user-admin-agent - Give the runtime's execution role these actions in addition to model access and logs:
bedrock-agentcore:GetWorkloadAccessTokenForJWTon the workload identity directory and its workload identitiesbedrock-agentcore:GetResourceOauth2Tokenon the token vault, theagent-oboprovider and the workload identitiessecretsmanager:GetSecretValueon the provider's client secret
- Create the runtime with a PingOne JWT authorizer for
<AGENT_AUD>, and allow theAuthorizationheader so the agent receives the user's token:Runtime settings (excerpt)"authorizerConfiguration": { "customJWTAuthorizer": { "discoveryUrl": "<DISCOVERY_URL>", "allowedAudience": ["<AGENT_AUD>"] } }, "requestHeaderConfiguration": { "requestHeaderAllowlist": ["Authorization"] } - Your chat app calls
InvokeAgentRuntimewithAuthorization: Bearer <the user's PingOne token>.
8. Set up PingOne Authorize API Access Management
API Access Management receives each Gateway request from the authorizer Lambda through its sideband API and applies your policy.
- Add an API gateway of the API gateway integration type, and create a credential for it. The credential is the Lambda's
SECRET_HEADER_VALUEin step 10. - Add an API service:
- For the base URL, enter the Gateway URL without
/mcp:https://<GATEWAY_ID>.gateway.bedrock-agentcore.<REGION>.amazonaws.com. - For the authorization server, choose PingOne SSO and the gateway resource from step 1.
- Turn on custom access control.
- For the base URL, enter the Gateway URL without
- Add one operation, MCP JSON-RPC: method
POST, exact path/mcp, custom access control. Every MCP request goes there. - In the Trust Framework, add the attributes the policy reads. API Access Management provides the body as a string and the headers as
{"name": ["value"]}.The user's permissions come fromAttribute Resolves from JSON path Type Default mcp-bodyPingOne.API Access Management.HTTP.Request.BodyJSON mcp-methodmcp-body$.methodString empty mcp-toolmcp-body$.params.nameString empty mcp-target-usermcp-body$.params.arguments.user_nameString empty mcp-target-emailmcp-body$.params.arguments.emailString empty ciba-approvalPingOne.API Access Management.HTTP.Request.Headers$['x-ciba-approval'][0]String nonePingOne.API Access Management.Identity.Access Token.Permissions, which API Access Management fills from the validated Gateway token.
9. Write the policy
Add one policy to the operation's Inbound Request custom policy set, with the First applicable combining algorithm. The set combines your policy with the generated "Permit by default" rules using deny-overrides, so only your denials change the outcome. These are the demo's rules. Gateway tool names have the form <target>___<tool>, so match them with ends with.
| # | When | Effect |
|---|---|---|
| 1 | mcp-method isn't tools/call (initialize, tools/list) | Permit |
| 2 | A read tool, and permissions lack users:read | Deny |
| 3 | A read tool (___find_users, ___get_user) | Permit |
| 4 | A delete tool, and permissions lack users:delete | Deny |
| 5 | A write tool, and permissions lack users:write | Deny |
| 6 | ___create_user with an email at your domain | Permit |
| 7 | A write or delete whose mcp-target-user isn't one of the managed accounts | Deny |
| 8 | A delete tool, and ciba-approval isn't approved | Ask for approval |
| 9 | Writes, and approved deletes | Permit |
| 10 | Anything else | Deny |
- Give every deny rule an
auth-challengestatement that applies to Deny, with the reason inerrorDescription. The sideband API returns no other statements, so this is how the reason reaches the user.auth-challenge payload for a denial (rule 4){ "httpStatus": 403, "error": "insufficient_scope", "errorDescription": "Your PingOne role does not allow you to delete or deactivate users (it lacks users:delete)" } - Make rule 8 an unconditional deny whose
auth-challengeasks for theciba-approvalACR. The authorizer starts a CIBA request instead of passing this challenge on.auth-challenge payload that asks for approval (rule 8){ "httpStatus": 401, "error": "insufficient_user_authentication", "acrValues": ["ciba-approval"], "errorDescription": "Deleting an account needs your approval on your other device" } - Deploy the API service. Deploy again after every policy change.
Keep every other deny rule ahead of the approval rule, so nobody is asked to approve a call the policy would refuse anyway. If the policy still asks for approval after one was given, the authorizer refuses the call with approval_not_accepted instead of asking again.
10. Deploy the authorizer Lambda
- Create the DynamoDB table for approval records and turn on expiry:
Create the table
aws dynamodb create-table --table-name mcp-ciba \ --attribute-definitions AttributeName=pk,AttributeType=S \ --key-schema AttributeName=pk,KeyType=HASH --billing-mode PAY_PER_REQUESTTurn on expiryaws dynamodb update-time-to-live --table-name mcp-ciba \ --time-to-live-specification Enabled=true,AttributeName=ttl - Create the Lambda's role, trusted by
lambda.amazonaws.com, with CloudWatch Logs anddynamodb:GetItemanddynamodb:PutItemon the table. - Create the function from
bootstrap.zip: runtimeprovided.al2023, architecturearm64, handlerbootstrap, timeout 30 seconds, 256 MB. An approved replay calls DynamoDB, PingOne and the sideband API in turn, so keep the timeout at 15 seconds or more.Create the functionaws lambda create-function --function-name mcp-authorizer \ --runtime provided.al2023 --architectures arm64 --handler bootstrap \ --timeout 30 --memory-size 256 \ --role arn:aws:iam::<ACCOUNT_ID>:role/mcp-authorizer \ --zip-file fileb://bootstrap.zip --environment file://authorizer-env.jsonauthorizer-env.json{ "Variables": { "SERVICE_URL": "https://http-access-api.pingone.com/v1/environments/<ENV_ID>", "SECRET_HEADER_VALUE": "<API gateway credential from step 8>", "AUTH_ERROR_MODE": "jsonrpc", "RESPONSE_AUTHORIZATION": "off", "CIBA_PROVIDER": "pingone", "CIBA_AUTHORIZATION_ENDPOINT": "<ISSUER>/cibaAuthorization", "CIBA_TOKEN_ENDPOINT": "<ISSUER>/token", "CIBA_CLIENT_ID": "<APPROVALS_APP_ID>", "CIBA_CLIENT_SECRET": "<APPROVALS_APP_SECRET>", "CIBA_TABLE": "mcp-ciba", "CIBA_LOGIN_HINT_CLAIM": "username", "CIBA_REQUESTED_EXPIRY": "300", "RUST_LOG": "info" } }Variable Why this value AUTH_ERROR_MODE=jsonrpcA policy denial reaches the agent as HTTP 200 with JSON-RPC -32081, whose message is the rule's reason.RESPONSE_AUTHORIZATION=offNo outbound policy runs. With it on, the authorizer passes state to the response phase in a header of about 6 KB, over AgentCore's 4 KB limit for a forwarded header. CIBA_LOGIN_HINT_CLAIM=usernameThe DaVinci flow finds the user by username, which the gateway resource's usernameclaim provides.CIBA_REQUESTED_EXPIRY=300PingOne's maximum. The 120-second default is often too short to find the email. - Add
lambda:InvokeFunctionon the function to the Gateway's role. - Make the function the Gateway's request and response interceptor, with request headers passed in. Add this to the Gateway with
update-gateway, keeping its other settings:interceptorConfigurations"interceptorConfigurations": [{ "interceptor": { "lambda": { "arn": "arn:aws:lambda:<REGION>:<ACCOUNT_ID>:function:mcp-authorizer" } }, "interceptionPoints": ["REQUEST", "RESPONSE"], "inputConfiguration": { "passRequestHeaders": true } }]
update-gateway replaces the whole configuration. Send the authorizer, role and protocol settings again with the interceptor, or the Gateway loses them.
11. Update the agent
- Exchange the user's token for a Gateway token on each invocation:
Python (boto3)
import boto3 identity = boto3.client("bedrock-agentcore") workload = identity.get_workload_access_token_for_jwt( workloadName="user-admin-agent", userToken=user_jwt)["workloadAccessToken"] gateway_token = identity.get_resource_oauth2_token( workloadIdentityToken=workload, resourceCredentialProviderName="agent-obo", scopes=["mcp:tools"], oauth2Flow="ON_BEHALF_OF_TOKEN_EXCHANGE", customParameters={"subject_token_type": "urn:ietf:params:oauth:token-type:access_token"}, )["accessToken"] - When a tool call returns
-32004, showdata.bindingMessageto the user, so they can match it to the approval request.Approval required (HTTP 403){ "jsonrpc": "2.0", "id": 7, "error": { "code": -32004, "message": "Approval required", "data": { "reason": "approval_required", "reference": "<auth_req_id>", "referenceMetaKey": "pingidentity.com/ciba-reference", "interval": 5, "bindingMessage": "APR-4821", "expiresIn": 300 } } } - Wait
data.intervalseconds, then send the same call with a new JSON-RPCidand the reference inparams._meta. Keep the arguments exactly as they were. Repeat while the reason isapproval_pending, untildata.expiresInruns out.Replay{ "jsonrpc": "2.0", "id": 8, "method": "tools/call", "params": { "name": "crm___delete_user", "arguments": { "user_name": "carol.martinez" }, "_meta": { "pingidentity.com/ciba-reference": "<auth_req_id>" } } } - Show refusals in plain words:
-32081(HTTP 200): a policy denial.error.messageis the rule'serrorDescription.-32080(HTTP 403): the authorizer refused.error.data.reasonsays why, for exampleapproval_denied,approval_expired,approval_already_usedorapproval_mismatch.
Test the setup
Sign in as users with each role and try these prompts. Test with real users: a client credentials token has no user to exchange for, so the targets list no tools.
| User | Prompt | Expected result |
|---|---|---|
| help-desk | "Look up Carol Martinez" | The tool runs. The MCP server receives a token with its own audience and act naming the Gateway and the agent. |
| help-desk | "Delete Carol Martinez" | Refused with -32081 and rule 4's reason. The MCP server is never called. |
| iam-admin | "Delete Carol Martinez" | -32004 with a code such as APR-4821. After the user approves a request showing the same code, the delete runs. |
| iam-admin | Replay an approved reference | Refused with -32080 and approval_already_used. |
Troubleshooting
- Users see no tools, and the Gateway logs "Token exchange failed."
- Check the target's
subject_token_typeoverride, the Gateway exchange app's resource access, and themay_actmapping on the gateway resource. An interceptor that returns anAuthorizationheader replaces the target's credential provider. The authorizer only returns one when the policy changed it. - Every reply is an empty
{} - The response interceptor returned nothing. AgentCore sends an empty interceptor output to the client as
{}. Use the authorizer build from the demo, which passes unchanged responses through explicitly. - Calls fail after you turn on response authorization
- The
x-paz-stateheader is over AgentCore's 4 KB limit for a forwarded header. KeepRESPONSE_AUTHORIZATION=offand leavex-paz-stateout ofallowedRequestHeaders. - The approval expires before the user finds it
- Set
CIBA_REQUESTED_EXPIRY=300, PingOne's maximum. - A policy change has no effect
- Deploy the API service again.
- The agent says only "Request denied"
- The deny rule has no
auth-challengestatement, orx-paz-response-headersisn't in the target'sallowedRequestHeaders.