I Tried Narrowing Scopes for MCP Using Cognito's OAuth 2.0 Token Exchange

I Tried Narrowing Scopes for MCP Using Cognito's OAuth 2.0 Token Exchange

Using Amazon Cognito's OAuth 2.0 token exchange (GetDerivedToken), I issued a scope-narrowed token for an MCP server and confirmed that API Gateway and AgentCore Gateway reject it for lacking the required scopes.
2026.11.15

This page has been translated by machine translation. View original

Introduction

On November 12, 2026, Amazon Cognito added support for OAuth 2.0 token exchange.

https://aws.amazon.com/jp/about-aws/whats-new/2026/11/cognito-token-exchange/

With token exchange, a service that calls downstream APIs on behalf of a user can obtain a new, scope-narrowed token from the user's token.

In this article, I performed this exchange with an app client standing in for an MCP server and compared the claims of the tokens before and after the exchange. I then passed both tokens to an API that returns an S3 listing built with API Gateway and Lambda, to the authorization scopes of API Gateway, and to AgentCore Gateway, and compared the results.

Preparation for the verification

Common resources

The user pool is on the Essentials plan in ap-northeast-1. I defined two scopes, public.read and private.read, on the resource server files. The S3 bucket holds two objects each under private/ and public/.

For user sign-in, I used USER_PASSWORD_AUTH. To obtain a token with files scopes without browser interaction, I added the scopes to the access token at sign-in with a Pre token generation Lambda. This Lambda only adds scopes; the token exchange itself is performed by Cognito.

App client settings

The app client that requests the exchange (mcp-server) specifies ALLOW_TOKEN_EXCHANGE_DELEGATION and ALLOW_TOKEN_EXCHANGE_IMPERSONATION in ExplicitAuthFlows. This app client is created with the following command.

aws cognito-idp create-user-pool-client --user-pool-id <user-pool-id> --client-name mcp-server --generate-secret --explicit-auth-flows ALLOW_TOKEN_EXCHANGE_DELEGATION ALLOW_TOKEN_EXCHANGE_IMPERSONATION ALLOW_REFRESH_TOKEN_AUTH --allowed-o-auth-flows code --allowed-o-auth-flows-user-pool-client --allowed-o-auth-scopes files/public.read files/private.read --callback-urls https://localhost/callback --supported-identity-providers COGNITO

For the actor token, I used an M2M token obtained with GetClientToken. An app client that uses GetClientToken must have ALLOW_CLIENT_TOKEN_AUTH as its only authentication flow. Because mcp-server has the exchange flows attached, I created a separate app client, mcp-actor-m2m.

Creating the app client for the actor token and obtaining the token
aws cognito-idp create-user-pool-client --user-pool-id <user-pool-id> --client-name mcp-actor-m2m --generate-secret --explicit-auth-flows ALLOW_CLIENT_TOKEN_AUTH --allowed-o-auth-flows client_credentials --allowed-o-auth-flows-user-pool-client --allowed-o-auth-scopes files/public.read

To obtain the token, pass a JSON file containing the ClientId and Secret of this app client. In GetClientToken, the key name for the secret is Secret, not ClientSecret. The token is contained under ClientAuthenticationResult in the response. The command and its output are shown below.

aws cognito-idp get-client-token --cli-input-json file:///tmp/actor-req.json
{
  "AccessToken": "<AccessToken: 862 chars>",
  "ExpiresIn": 3600,
  "TokenType": "Bearer"
}

How to call GetDerivedToken

The CLI (aws-cli/2.40.3) and SDK (boto3/1.44.3) available at the time of writing did not support get-derived-token, so I called the API directly. No signature is required; you can call it by putting the ClientId and ClientSecret in the request body. The list of parameters is in the API Reference.

https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_GetDerivedToken.html

I specified the user's access token in SubjectToken and files/public.read in Scopes. No actor token was attached.

request: {"ClientId": "<mcp-server-client-id>", "ClientSecret": "<ClientSecret: 51 chars>", "SubjectToken": "<SubjectToken: 1089 chars>", "SubjectTokenType": "ACCESS_TOKEN", "RequestedTokenType": "ACCESS_TOKEN", "Scopes": ["files/public.read"]}
HTTP 200
response: {
  "ExpiresIn": 3562,
  "IssuedToken": "<IssuedToken: 973 chars>",
  "IssuedTokenType": "ACCESS_TOKEN",
  "Scopes": [
    "files/public.read"
  ],
  "TokenType": "Bearer"
}

The script used for the call takes the request JSON file and the destination for saving the issued token as arguments. Because it does not use an SDK, the script sets Content-Type and X-Amz-Target itself.

get-derived-token.py
#!/usr/bin/env python3
"""GetDerivedToken を HTTPS で直接呼ぶ(2026-11-13 時点で CLI/SDK に無いため)。
使い方: get-derived-token.py <リクエストJSONのファイル> [発行トークンの保存先]
- 署名なし。ClientId(+ ClientSecret)で認証する。
- 標準出力には、秘匿値(ClientSecret・SubjectToken・ActorToken・IssuedToken)を伏せたリクエストと応答を出す。
"""
import json, sys, urllib.request, urllib.error

REGION = "ap-northeast-1"
req = json.load(open(sys.argv[1]))
mask = lambda d: {k: (f"<{k}: {len(v)} chars>" if k in ("ClientSecret", "SubjectToken", "ActorToken", "IssuedToken") else v) for k, v in d.items()}
print("request:", json.dumps(mask(req), ensure_ascii=False))
r = urllib.request.Request(
    f"https://cognito-idp.{REGION}.amazonaws.com/",
    data=json.dumps(req).encode(),
    headers={
        "Content-Type": "application/x-amz-json-1.1",
        "X-Amz-Target": "AWSCognitoIdentityProviderService.GetDerivedToken",
    },
)
try:
    resp = urllib.request.urlopen(r)
    status, body = resp.status, resp.read()
except urllib.error.HTTPError as e:
    status, body = e.code, e.read()
print("HTTP", status)
out = json.loads(body)
if "IssuedToken" in out and len(sys.argv) > 2:
    open(sys.argv[2], "w").write(out["IssuedToken"])
print("response:", json.dumps(mask(out), ensure_ascii=False, indent=2))
sys.exit(0 if status == 200 else 1)

An API that checks scopes in Lambda

The API is a REST API in API Gateway with a Cognito authorizer attached, and a Lambda behind it. The Lambda returns an S3 listing according to the scopes of the token.

  • If files/private.read is present: a listing of the entire bucket
  • If only files/public.read is present: a listing under public/

The Lambda's role has s3:ListBucket on the entire bucket. The difference in results comes from this Lambda's scope check, not from S3 permissions.

Lambda for the API (index.mjs)
// Returns an S3 listing behind API Gateway (Cognito authorizer).
// Changes the Prefix of ListObjectsV2 depending on the scope. The check is done inside this Lambda.
import { S3Client, ListObjectsV2Command } from "@aws-sdk/client-s3";

const s3 = new S3Client({});

export const handler = async (event) => {
  const claims = event.requestContext.authorizer.claims;
  const scopes = (claims.scope || "").split(" ");
  const prefix = scopes.includes("files/private.read")
    ? ""
    : scopes.includes("files/public.read")
      ? "public/"
      : null;
  if (prefix === null) {
    return { statusCode: 403, body: JSON.stringify({ message: "no files scope" }) };
  }
  const res = await s3.send(
    new ListObjectsV2Command({ Bucket: process.env.BUCKET, Prefix: prefix }),
  );
  return {
    statusCode: 200,
    headers: { "content-type": "application/json" },
    body: JSON.stringify({
      scope: claims.scope,
      aud: claims.aud ?? null,
      client_id: claims.client_id ?? null,
      act: claims.act ?? null,
      prefix,
      keys: (res.Contents ?? []).map((o) => o.Key),
    }),
  };
};

Because the API is called with an access token, I set authorization scopes on the method. Here it is configured to let through tokens that have either of the two scopes.

Command to create the API Gateway method
aws apigateway put-method --rest-api-id <rest-api-id> --resource-id <resource-id> --http-method GET --authorization-type COGNITO_USER_POOLS --authorizer-id <authorizer-id> --authorization-scopes files/public.read files/private.read

API Gateway authorization scopes

Separately from the scope check written in the Lambda, API Gateway has a feature that rejects requests based on scopes. I passed the user's token and the narrowed token and compared them.

I added GET /private to the existing API. Its authorization scope is only files/private.read, and it is connected to the same Lambda.

aws apigateway put-method --rest-api-id <rest-api-id> --resource-id <resource-id> --http-method GET --authorization-type COGNITO_USER_POOLS --authorizer-id <authorizer-id> --authorization-scopes files/private.read

AgentCore Gateway allowedScopes

The JWT authorizer of AgentCore Gateway also has a feature that rejects requests based on scopes. I passed the same two tokens and compared them.

I created the AgentCore Gateway in ap-northeast-1 with MCP as the protocol. The JWT authorizer is configured as follows.

  • discovery URL: the URL of the Cognito user pool
  • allowedClients: the app client for users and mcp-server (because the client_id of the narrowed token is mcp-server)
  • allowedScopes: files/private.read

The target is a Lambda with a list_files tool that returns the keys of the entire bucket. I sent the MCP requests (tools/list and tools/call) with curl.

Commands to create the AgentCore Gateway and the tool Lambda

This is the authorizer configuration for the AgentCore Gateway (gw-authorizer.json) and the command to create the Gateway.

{"customJWTAuthorizer":{"discoveryUrl":"https://cognito-idp.ap-northeast-1.amazonaws.com/<user-pool-id>/.well-known/openid-configuration","allowedClients":["<user-client-id>","<mcp-server-client-id>"],"allowedScopes":["files/private.read"]}}
aws bedrock-agentcore-control create-gateway --name devio-tx-1113-gw --role-arn <gateway-role-arn> --protocol-type MCP --authorizer-type CUSTOM_JWT --authorizer-configuration file://gw-authorizer.json

This is the command to create the target. For credential-provider-configurations, I passed gw-target-cred.json, which contains [{"credentialProviderType":"GATEWAY_IAM_ROLE"}].

aws bedrock-agentcore-control create-gateway-target --gateway-identifier <gateway-id> --name tool --target-configuration '{"mcp":{"lambda":{"lambdaArn":"<tool-lambda-arn>","toolSchema":{"inlinePayload":[{"name":"list_files","description":"List all object keys in the bucket","inputSchema":{"type":"object","properties":{}}}]}}}}' --credential-provider-configurations file://gw-target-cred.json

This is the tool Lambda (index.mjs).

// For the Lambda target of AgentCore Gateway. Contains no scope check and returns a listing of the entire bucket.
import { S3Client, ListObjectsV2Command } from "@aws-sdk/client-s3";

const s3 = new S3Client({});

export const handler = async () => {
  const res = await s3.send(new ListObjectsV2Command({ Bucket: process.env.BUCKET }));
  return { keys: (res.Contents ?? []).map((o) => o.Key) };
};

The call takes the following form, with the token in the Authorization header. For tools/list, I set the method to tools/list and removed params.

curl -sS -i -X POST -H "Authorization: Bearer <token>" -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"tool___list_files","arguments":{}}}' https://<gateway-id>.gateway.bedrock-agentcore.ap-northeast-1.amazonaws.com/mcp

Verification results

Results of the Lambda check (S3 listing)

I passed the user's token and the token obtained by the exchange to the same API. The bucket being listed is the same.

The user's token:

{"scope":"aws.cognito.signin.user.admin files/private.read files/public.read","aud":null,"client_id":"<user-client-id>","act":null,"prefix":"","keys":["private/contract.pdf","private/salary.csv","public/readme.txt","public/sample.csv"]}

The token obtained by the exchange:

{"scope":"files/public.read","aud":null,"client_id":"<mcp-server-client-id>","act":null,"prefix":"public/","keys":["public/readme.txt","public/sample.csv"]}

Results with API Gateway authorization scopes

When passed to GET /private, the user's token returned 4 items with HTTP 200. The narrowed token returned 2 items in the API of the previous section, but was rejected with HTTP 401 Unauthorized at GET /private.

Results with AgentCore Gateway allowedScopes

With the user's token, tools/call returned 4 items with HTTP 200. The narrowed token was rejected with HTTP 403 insufficient_scope (scope="files/private.read") for both tools/list and tools/call. Since no scope check is written in the tool Lambda, it was the Gateway that rejected the request.

Changes in claims

I compared the claims before and after the exchange. The three columns on the right are all tokens exchanged at the request of mcp-server. The actor token and Audiences specification differ by column.

Claim User's token After exchange After exchange (with actor token) After exchange (with actor token, Audiences set to files)
scope aws.cognito.signin.user.admin files/private.read files/public.read files/public.read files/public.read files/public.read
client_id App client for users mcp-server mcp-server mcp-server
act None None mcp-actor-m2m mcp-actor-m2m
aud None None None files
sub, origin_jti Values of the user's token Same as left Same as left Same as left
exp 1794543505 1794543504 1794543504 1794543504

A downstream API can identify the user by sub and the app client that requested the exchange by client_id. The expiration is up to that of the original token, and in this case exp was 1 second earlier than the original token. ExpiresIn in the response was 3562, which was the number of seconds until the expiration of the exchanged token. The response did not include a refresh token.

Actor token and the act claim

I performed the exchange with an actor token attached. For ActorToken I specified the access token obtained with GetClientToken, and for ActorTokenType I specified ACCESS_TOKEN. The issued token contained an act claim.

"act": {
  "sub": "<mcp-actor-client-id>",
  "client_id": "<mcp-actor-client-id>",
  "iss": "https://cognito-idp.ap-northeast-1.amazonaws.com/<user-pool-id>",
  "token_type": "urn:ietf:params:oauth:token-type:access_token"
}

sub and client_id are the app client ID of mcp-actor-m2m, from which the actor token was obtained.

When this token was passed to Lambda via the Cognito authorizer of the REST API, the act that Lambda received was a JSON string, not an object.

{"scope":"files/public.read","aud":null,"client_id":"<mcp-server-client-id>","act":"{\"sub\":\"<mcp-actor-client-id>\",\"client_id\":\"<mcp-actor-client-id>\",\"iss\":\"https://cognito-idp.ap-northeast-1.amazonaws.com/<user-pool-id>\",\"token_type\":\"urn:ietf:params:oauth:token-type:access_token\"}","prefix":"public/","keys":["public/readme.txt","public/sample.csv"]}

When using the contents of act on the Lambda side, you need to parse it as JSON.

Specifying the audience

When I specified the resource server identifier files in Audiences, the aud of the issued token became files. When I specified the unregistered mcp-files-api, it resulted in HTTP 400 NotAuthorizedException (Invalid resource requested).

Scope requests and errors

When I used the narrowed token as SubjectToken and requested Scopes including files/private.read, which that token does not have, an error occurred. The API Reference states that the intersection of the requested scopes, the scopes of the SubjectToken, and the scopes allowed for the app client is issued. However, in the second case, an error occurred even though files/public.read is in the intersection.

SubjectToken ActorToken Scopes Result
Narrowed token None files/private.read HTTP 400 InvalidParameterException: Requested scopes are not permitted for this subject token
Narrowed token None files/public.read, files/private.read HTTP 400 InvalidParameterException: Requested scopes are not permitted for this subject token
User's token Yes aws.cognito.signin.user.admin, files/public.read HTTP 400 InvalidParameterException: Requested scopes are not permitted for this client
User's token Yes Omitted HTTP 200, files/private.read and files/public.read

When Scopes was omitted, aws.cognito.signin.user.admin, which the user's token had, was not included.

Summary

With Cognito token exchange, you can now issue a separate, scope-narrowed token from a user's token.

The narrowed token was rejected by API Gateway and AgentCore Gateway, which require files/private.read, and the Lambda scope check returned only the two items under public/. If an MCP server narrows the scopes of the token it passes to downstream APIs, then even if that token is leaked or misused, the operations that can be performed are limited to the range of those scopes.

If you call downstream APIs that can authorize by Cognito scopes, such as API Gateway or AgentCore Gateway, from an MCP server, please try the newly supported token exchange.

Share this article

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