
I Tried Narrowing Scopes for MCP Using Cognito's OAuth 2.0 Token Exchange
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.
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.
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.
