Identity for AI demos Setup guide
Demo guidesAmazon Bedrock AgentCore

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

How it works

ArchitectureThe user signs in with PingOne SSO and chats through the chat app with the agent on AgentCore Runtime. The agent exchanges the user's token through AgentCore Identity and calls tools on AgentCore Gateway. The Gateway runs the Lambda authorizer, which asks PingOne Authorize, starts PingOne CIBA approvals that reach the user by email, and records approvals in DynamoDB. The Gateway then calls your MCP servers with a token minted for each server.PingOneAWS CloudYour MCP serversUserChat appYour frontendPingOne SSOSign-in, token exchangePingOne AuthorizeAPI Access ManagementPingOne CIBADaVinci approval flowUser's emailApprove or declineAgentCore IdentityCredential providersAmazon DynamoDBApproval recordsAWS LambdaAuthorizerAgentCore RuntimeAgentAgentCore GatewayMCP toolsMCP serverTarget 1MCP serverTarget 21Sign inPrompt + user token2Exchange2On-behalf-of token2tools/call + Gateway token3Interceptor3Sideband decision4CIBA request4Email56Server's own token
Numbers match the steps below. Both token exchanges go through AgentCore Identity and PingOne SSO: the agent's for the Gateway token, and the Gateway's for each MCP server's token.
  1. 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.
  2. The agent gets a Gateway token.AgentCore Identity exchanges the user's token for one valid only at the Gateway, with act naming the agent.
  3. 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.
  4. 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 -32004 and a reference. The agent shows the approval code and replays the call with the reference until the user answers.
  5. 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.
  6. The MCP server gets its own token.The Gateway exchanges the Gateway token for one valid only at that MCP server, with act naming the Gateway and the agent.
Note

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:

Values used in this guide

PlaceholderWhat 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.

  1. Sign on to the PingOne admin console and go to Applications > Resources.
  2. 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.
    ResourceAudienceScopesPermissions claim
    agent-runtime<AGENT_AUD>agent:invokeOn
    gateway<GATEWAY_AUD>mcp:toolsOn
    actoran 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
  3. Add the attribute mappings below on each resource's Attributes tab. Replace the client IDs once you have created the applications in step 2.
    ResourceAttributeValue
    agent-runtimemay_act${{'sub': '<AGENT_APP_ID>'}}
    gatewaysub${#root.context.requestData.subjectToken.sub}
    gatewayact (required)First-hop expression below
    gatewaymay_act${{'sub': '<GATEWAY_APP_ID>'}}
    gatewayusername${user.username}, used as the CIBA login_hint
    each MCP serversub${#root.context.requestData.subjectToken.sub}
    each MCP serveract (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)}
  4. Mark both act mappings as required ("required": true on the resource attribute). A required claim whose expression returns null makes PingOne refuse the token, so an exchange by an actor the subject token didn't name in may_act fails instead of producing a token without act.

2. Create the applications

  1. 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.
    ApplicationType and grantsResource access
    Chat appOIDC Web App: Authorization Code, PKCE required (S256), your redirect URIagent-runtime: agent:invoke, plus openid
    AgentAI Agent (or a machine-to-machine service app): Client Credentials and Token Exchangegateway: mcp:tools; actor: actor
    Gateway exchangeMachine-to-machine service app: Client Credentials and Token Exchangeeach MCP server: all its scopes; actor: actor
    ApprovalsOIDC Web App with the CIBA grantopenid
  2. Copy each application's client ID into the may_act mappings from step 1: the agent's ID on agent-runtime, the Gateway exchange app's ID on gateway.
  3. 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.

  1. On the agent-runtime and gateway resources, add an application resource named users with the permissions read, write and delete. Tokens list them as users:read, users:write and users:delete.
  2. Create application roles and give each the permissions it allows. The demo uses:
    • help-desk: read, write
    • iam-admin: read, write, delete
  3. 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

  1. 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/.
  2. Assign the flow to the approvals app with a DaVinci flow policy.
  3. 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.
Note

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.

  1. Create agent-obo with 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 it
    aws bedrock-agentcore-control create-oauth2-credential-provider --cli-input-json file://agent-obo.json
  2. Create gateway-obo the same way with the Gateway exchange app's client ID and secret.

6. Create the Gateway and its targets

  1. Create the Gateway's IAM role, trusted by bedrock-agentcore.amazonaws.com. It needs bedrock-agentcore:* for the credential providers, secretsmanager:GetSecretValue on arn:aws:secretsmanager:<REGION>:<ACCOUNT_ID>:secret:bedrock-agentcore-identity!*, and later lambda:InvokeFunction on the authorizer (step 10).
  2. 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"]
        }
      }
    }
    Create it
    aws bedrock-agentcore-control create-gateway --cli-input-json file://gateway.json
    Note the gatewayUrl in the response (https://<GATEWAY_ID>.gateway.bedrock-agentcore.<REGION>.amazonaws.com/mcp). You need it in step 8.
  3. Add one target per MCP server. The target exchanges the Gateway token through gateway-obo for the server's own token. Allow the x-paz-response-headers header, 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 it
    aws bedrock-agentcore-control create-gateway-target --cli-input-json file://target-crm.json
Important

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

  1. Create a workload identity for the agent:
    Create it
    aws bedrock-agentcore-control create-workload-identity --name user-admin-agent
  2. Give the runtime's execution role these actions in addition to model access and logs:
    • bedrock-agentcore:GetWorkloadAccessTokenForJWT on the workload identity directory and its workload identities
    • bedrock-agentcore:GetResourceOauth2Token on the token vault, the agent-obo provider and the workload identities
    • secretsmanager:GetSecretValue on the provider's client secret
  3. Create the runtime with a PingOne JWT authorizer for <AGENT_AUD>, and allow the Authorization header so the agent receives the user's token:
    Runtime settings (excerpt)
    "authorizerConfiguration": {
      "customJWTAuthorizer": {
        "discoveryUrl": "<DISCOVERY_URL>",
        "allowedAudience": ["<AGENT_AUD>"]
      }
    },
    "requestHeaderConfiguration": { "requestHeaderAllowlist": ["Authorization"] }
  4. Your chat app calls InvokeAgentRuntime with Authorization: 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.

  1. Add an API gateway of the API gateway integration type, and create a credential for it. The credential is the Lambda's SECRET_HEADER_VALUE in step 10.
  2. Add an API service:
    1. For the base URL, enter the Gateway URL without /mcp: https://<GATEWAY_ID>.gateway.bedrock-agentcore.<REGION>.amazonaws.com.
    2. For the authorization server, choose PingOne SSO and the gateway resource from step 1.
    3. Turn on custom access control.
  3. Add one operation, MCP JSON-RPC: method POST, exact path /mcp, custom access control. Every MCP request goes there.
  4. 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"]}.
    AttributeResolves fromJSON pathTypeDefault
    mcp-bodyPingOne.API Access Management.HTTP.Request.BodyJSON
    mcp-methodmcp-body$.methodStringempty
    mcp-toolmcp-body$.params.nameStringempty
    mcp-target-usermcp-body$.params.arguments.user_nameStringempty
    mcp-target-emailmcp-body$.params.arguments.emailStringempty
    ciba-approvalPingOne.API Access Management.HTTP.Request.Headers$['x-ciba-approval'][0]Stringnone
    The user's permissions come from PingOne.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.

#WhenEffect
1mcp-method isn't tools/call (initialize, tools/list)Permit
2A read tool, and permissions lack users:readDeny
3A read tool (___find_users, ___get_user)Permit
4A delete tool, and permissions lack users:deleteDeny
5A write tool, and permissions lack users:writeDeny
6___create_user with an email at your domainPermit
7A write or delete whose mcp-target-user isn't one of the managed accountsDeny
8A delete tool, and ciba-approval isn't approvedAsk for approval
9Writes, and approved deletesPermit
10Anything elseDeny
  1. Give every deny rule an auth-challenge statement that applies to Deny, with the reason in errorDescription. 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)"
    }
  2. Make rule 8 an unconditional deny whose auth-challenge asks for the ciba-approval ACR. 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"
    }
  3. Deploy the API service. Deploy again after every policy change.
Note

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

  1. 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_REQUEST
    Turn on expiry
    aws dynamodb update-time-to-live --table-name mcp-ciba \
      --time-to-live-specification Enabled=true,AttributeName=ttl
  2. Create the Lambda's role, trusted by lambda.amazonaws.com, with CloudWatch Logs and dynamodb:GetItem and dynamodb:PutItem on the table.
  3. Create the function from bootstrap.zip: runtime provided.al2023, architecture arm64, handler bootstrap, 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 function
    aws 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.json
    authorizer-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"
      }
    }
    VariableWhy 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 username claim provides.
    CIBA_REQUESTED_EXPIRY=300PingOne's maximum. The 120-second default is often too short to find the email.
  4. Add lambda:InvokeFunction on the function to the Gateway's role.
  5. 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 }
    }]
Important

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

  1. 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"]
  2. When a tool call returns -32004, show data.bindingMessage to 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
        }
      }
    }
  3. Wait data.interval seconds, then send the same call with a new JSON-RPC id and the reference in params._meta. Keep the arguments exactly as they were. Repeat while the reason is approval_pending, until data.expiresIn runs 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>" }
      }
    }
  4. Show refusals in plain words:
    • -32081 (HTTP 200): a policy denial. error.message is the rule's errorDescription.
    • -32080 (HTTP 403): the authorizer refused. error.data.reason says why, for example approval_denied, approval_expired, approval_already_used or approval_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.

UserPromptExpected 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-adminReplay an approved referenceRefused with -32080 and approval_already_used.

Troubleshooting

Users see no tools, and the Gateway logs "Token exchange failed."
Check the target's subject_token_type override, the Gateway exchange app's resource access, and the may_act mapping on the gateway resource. An interceptor that returns an Authorization header 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-state header is over AgentCore's 4 KB limit for a forwarded header. Keep RESPONSE_AUTHORIZATION=off and leave x-paz-state out of allowedRequestHeaders.
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-challenge statement, or x-paz-response-headers isn't in the target's allowedRequestHeaders.
Based on the demo as deployed on 3 November 2026: AgentCore Gateway with two MCP targets, PingOne Authorize API Access Management, and PingOne CIBA by email.