![[Tip] Tried passing custom headers to the AgentCore Gateway interceptor](https://images.ctfassets.net/ct0aopd36mqt/7M0d5bjsd0K4Et30cVFvB6/5b2095750cc8bf73f04f63ed0d4b3546/AgentCore2.png?w=3840&fm=webp)
[Tip] Tried passing custom headers to the AgentCore Gateway interceptor
This page has been translated by machine translation. View original
Introduction
Hello, I'm Jinno from the Consulting Department, and I want to get better at driving. (I'm crying because I scratched my brand new car.)
Amazon Bedrock AgentCore Gateway has a mechanism called interceptors, which allows you to insert Lambda function-based validation and transformation before and after tool calls. I've previously written blog posts about both request and response interceptors.
While considering using this mechanism, I became curious: do custom HTTP headers attached by an MCP client actually reach the interceptor?
This is an important point related to whether you can design a system where unique information such as the calling application identifier is passed via headers and used on the interceptor side. I built an actual environment using Gateway and Cognito to verify this!
The Conclusion Up Front
It is possible to send custom headers to the interceptor. However, there are conditions.
- Request headers are only passed to
gatewayRequest.headerswhenpassRequestHeadersis set totruein the interceptor configuration - When
passRequestHeadersis not configured, in this verificationheaderswas an empty map{}and no custom headers were passed at all - The custom headers I added arrived in lowercase (X-Dept-Id → x-dept-id). It is safer to handle them in lowercase when referencing them
- Enabling
passRequestHeadersalso passes the Authorization header (Bearer token) as-is, so care must be taken with log output
Note that whether something arrives and whether it is acceptable to use it for control are separate issues. Custom headers are values that clients can freely rewrite, so using them for authorization and similar controls is not recommended. They can only be an option when the configuration guarantees that headers cannot be tampered with en route (such as when a trusted proxy adds or overwrites them). I will touch on this point in the latter half of the article.
In the following sections, I will walk through building the verification environment and confirming the actual behavior.
Prerequisites
The official documentation states the following as a security best practice for interceptors:
By default, request headers will not be passed to an interceptor unless the
passRequestHeadersfield is set to true. Be careful when using this field as request headers can contain sensitive information such as authentication tokens and credentials.
In other words, headers are not passed by default, and the design requires explicit opt-in. Now that I understand the documented specification, let me verify what form custom headers actually arrive in.
The verification environment is as follows:
- Region: us-east-1
- boto3 / botocore: 1.43.43
- Inbound authentication: Amazon Cognito User Pool (client_credentials flow)
- Interceptor: Python 3.13 Lambda function (REQUEST interceptor)
The steps for creating the Cognito User Pool, resource server, and app client are not the main topic, so I'll collapse them.
Cognito resource creation commands
# User Pool
aws cognito-idp create-user-pool \
--pool-name header-demo-pool \
--query 'UserPool.Id' --output text
# => us-east-1_xxxxxxxxx
# Resource server
aws cognito-idp create-resource-server \
--user-pool-id us-east-1_xxxxxxxxx \
--identifier gateway-api \
--name "Gateway API" \
--scopes ScopeName=invoke,ScopeDescription="Invoke gateway tools"
# Domain for token endpoint
aws cognito-idp create-user-pool-domain \
--domain header-demo-xxxxxxxx \
--user-pool-id us-east-1_xxxxxxxxx
# App client for client_credentials flow
aws cognito-idp create-user-pool-client \
--user-pool-id us-east-1_xxxxxxxxx \
--client-name gateway-m2m-client \
--generate-secret \
--allowed-o-auth-flows client_credentials \
--allowed-o-auth-scopes "gateway-api/invoke" \
--allowed-o-auth-flows-user-pool-client
Setup
Creating the Interceptor Lambda
First, I'll create an interceptor Lambda that simply logs the received event as-is and passes it through. The goal is to observe what arrives.
import json
def lambda_handler(event, context):
print("INTERCEPTOR_EVENT " + json.dumps(event, ensure_ascii=False))
body = event.get("mcp", {}).get("gatewayRequest", {}).get("body", {})
return {
"interceptorOutputVersion": "1.0",
"mcp": {
"transformedGatewayRequest": {"body": body}
},
}
A REQUEST interceptor must return interceptorOutputVersion as "1.0" and put the request body to forward in mcp.transformedGatewayRequest. Here, I'm passing through by returning the received body as-is. The input/output payload formats are documented in detail here:
Once the code is ready, I'll create an execution role and deploy the Lambda function.
# Create Lambda execution role
cat > lambda-trust.json <<'EOF'
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"Service": "lambda.amazonaws.com"},
"Action": "sts:AssumeRole"
}]
}
EOF
aws iam create-role \
--role-name header-demo-lambda-role \
--assume-role-policy-document file://lambda-trust.json
aws iam attach-role-policy \
--role-name header-demo-lambda-role \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
# Zip and deploy
cd interceptor && zip ../interceptor.zip lambda_function.py && cd ..
aws lambda create-function \
--function-name header-demo-interceptor \
--runtime python3.13 \
--role arn:aws:iam::123456789012:role/header-demo-lambda-role \
--handler lambda_function.lambda_handler \
--zip-file fileb://interceptor.zip \
--timeout 10
Only AWSLambdaBasicExecutionRole is attached to the execution role. Since I'll check the event contents in CloudWatch Logs, log output permissions are sufficient.
Creating the Gateway Execution Role
I'll create an execution role so that Gateway can invoke the interceptor and target Lambda functions.
cat > gateway-trust.json <<'EOF'
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"Service": "bedrock-agentcore.amazonaws.com"},
"Action": "sts:AssumeRole",
"Condition": {"StringEquals": {"aws:SourceAccount": "123456789012"}}
}]
}
EOF
aws iam create-role \
--role-name header-demo-gateway-role \
--assume-role-policy-document file://gateway-trust.json
cat > gateway-policy.json <<'EOF'
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["lambda:InvokeFunction"],
"Resource": [
"arn:aws:lambda:us-east-1:123456789012:function:header-demo-interceptor",
"arn:aws:lambda:us-east-1:123456789012:function:header-demo-tool"
]
}]
}
EOF
aws iam put-role-policy \
--role-name header-demo-gateway-role \
--policy-name lambda-invoke \
--policy-document file://gateway-policy.json
Following the official best practices, permissions are scoped to only invoking the interceptor and tool Lambda functions.
Creating the Gateway
I'll create a Gateway with an interceptor configuration. The key point here is setting passRequestHeaders to true in inputConfiguration.
import boto3
client = boto3.client("bedrock-agentcore-control", region_name="us-east-1")
response = client.create_gateway(
name="header-demo",
roleArn="arn:aws:iam::123456789012:role/header-demo-gateway-role",
protocolType="MCP",
authorizerType="CUSTOM_JWT",
authorizerConfiguration={
"customJWTAuthorizer": {
"discoveryUrl": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_xxxxxxxxx/.well-known/openid-configuration",
"allowedClients": ["<Cognito app client ID>"],
}
},
interceptorConfigurations=[
{
"interceptor": {
"lambda": {
"arn": "arn:aws:lambda:us-east-1:123456789012:function:header-demo-interceptor"
}
},
"interceptionPoints": ["REQUEST"],
"inputConfiguration": {"passRequestHeaders": True},
}
],
)
print(response["gatewayUrl"])
The interceptor configuration items are as follows:
| Configuration Item | Value | Description |
|---|---|---|
| interceptor.lambda.arn | Lambda ARN | The Lambda function to call as interceptor |
| interceptionPoints | REQUEST | Execute before calling target (RESPONSE can also be specified) |
| inputConfiguration.passRequestHeaders | True | Pass request headers to the interceptor |
Specify the Gateway execution role created earlier for roleArn. There is a constraint that each Gateway can have at most one interceptor each for REQUEST and RESPONSE.
As a tool for verification, I'll register a Lambda function that simply returns a greeting as a target.
Tool Lambda and target registration
def lambda_handler(event, context):
name = event.get('name', 'world')
return {'greeting': f'Hello, {name}!'}
zip tool.zip lambda_function.py
aws lambda create-function \
--function-name header-demo-tool \
--runtime python3.13 \
--role arn:aws:iam::123456789012:role/header-demo-lambda-role \
--handler lambda_function.lambda_handler \
--zip-file fileb://tool.zip
client.create_gateway_target(
gatewayIdentifier="<Gateway ID>",
name="greeting-tool",
targetConfiguration={
"mcp": {
"lambda": {
"lambdaArn": "arn:aws:lambda:us-east-1:123456789012:function:header-demo-tool",
"toolSchema": {
"inlinePayload": [
{
"name": "greet",
"description": "Return a greeting for the given name",
"inputSchema": {
"type": "object",
"properties": {"name": {"type": "string"}},
"required": ["name"],
},
}
]
},
}
}
},
credentialProviderConfigurations=[{"credentialProviderType": "GATEWAY_IAM_ROLE"}],
)
That completes the preparation. Let's try it out!
Verification
Calling a Tool with Custom Headers
In addition to the token obtained from Cognito, I'll call the tool with three custom headers attached.
curl -s -X POST https://<Gateway ID>.gateway.bedrock-agentcore.us-east-1.amazonaws.com/mcp \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'X-Dept-Id: dept-a' \
-H 'X-App-Id: hr-agent-001' \
-H 'X-Trace-Note: custom-header-demo' \
-d '{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"greeting-tool___greet","arguments":{"name":"Interceptor"}}}'
{
"jsonrpc": "2.0",
"id": 10,
"result": {
"isError": false,
"content": [
{"type": "text", "text": "{\"greeting\":\"Hello, Interceptor!\"}"}
]
}
}
The call succeeded without issues via the interceptor. Now for the main point — let me check the event that arrived at the interceptor Lambda in CloudWatch Logs.
{
"interceptorInputVersion": "1.0",
"mcp": {
"gatewayRequest": {
"path": "/mcp",
"httpMethod": "POST",
"headers": {
"Host": "<Gateway ID>.gateway.bedrock-agentcore.us-east-1.amazonaws.com",
"authorization": "Bearer eyJraWQ...",
"accept": "application/json, text/event-stream",
"content-type": "application/json",
"user-agent": "curl/8.7.1",
"x-dept-id": "dept-a",
"x-app-id": "hr-agent-001",
"x-trace-note": "custom-header-demo",
"X-Forwarded-For": "203.0.113.10",
"X-Amzn-Trace-Id": "Root=1-...",
"x-amzn-tls-version": "TLSv1.3"
},
"body": {
"id": 10,
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": "greeting-tool___greet",
"arguments": {"name": "Interceptor"}
}
}
},
"rawGatewayRequest": {
"body": "{\"jsonrpc\":\"2.0\",\"id\":10,...}"
}
}
}
Oh, all three custom headers arrived! The custom headers I added arrived normalized to lowercase as x-dept-id / x-app-id / x-trace-note. On the other hand, headers containing uppercase letters such as Host and X-Forwarded-For are mixed in, so it is safer to lowercase the keys when referencing them on the interceptor side. Additionally, I can see that metadata such as the client's source IP (X-Forwarded-For) and User-Agent also arrive together.
Comparing with the Case Without passRequestHeaders
For comparison, I also tried the same call with inputConfiguration removed (i.e., passRequestHeaders not set) using update-gateway.
{
"interceptorInputVersion": "1.0",
"mcp": {
"gatewayRequest": {
"path": "/mcp",
"httpMethod": "POST",
"headers": {},
"body": {"id": 11, "jsonrpc": "2.0", "method": "tools/call", ...}
}
}
}
headers became an empty map. Not only the custom headers but also the Authorization header was not included at all. This confirms that explicitly enabling passRequestHeaders is required if you want to use headers for control!
Trying Control Based on Received Headers
Now that I know headers can be obtained, let me also verify as a behavioral check whether header-based control is technically feasible. This is an implementation where if X-Dept-Id is not in the allowed list, the interceptor short-circuits with an error response without calling the target. As mentioned later, adopting this approach as authorization in production is not recommended. This is purely a check to see if it works.
import json
ALLOWED_DEPTS = {"dept-a"}
def lambda_handler(event, context):
req = event.get("mcp", {}).get("gatewayRequest", {})
headers = {k.lower(): v for k, v in (req.get("headers") or {}).items()}
body = req.get("body", {})
dept = headers.get("x-dept-id")
if body.get("method") == "tools/call" and dept not in ALLOWED_DEPTS:
return {
"interceptorOutputVersion": "1.0",
"mcp": {
"transformedGatewayResponse": {
"statusCode": 403,
"body": {
"jsonrpc": "2.0",
"id": body.get("id"),
"error": {
"code": -32000,
"message": f"Access denied: department '{dept}' is not allowed to call tools",
},
},
}
},
}
return {
"interceptorOutputVersion": "1.0",
"mcp": {"transformedGatewayRequest": {"body": body}},
}
Returning transformedGatewayResponse causes Gateway to immediately return its contents to the client without calling the target. The result of calling with X-Dept-Id: dept-b is as follows:
{"jsonrpc":"2.0","id":21,"error":{"code":-32000,"message":"Access denied: department 'dept-b' is not allowed to call tools"}}
HTTP_STATUS=403
It was rejected with HTTP 403 and a JSON-RPC error! Since calls with dept-a still succeed as before, I was able to confirm that header-based control works.
Conclusion
It was a clear opt-in design where the visibility of headers switches completely with a single passRequestHeaders setting. When using custom headers, enabling passRequestHeaders explicitly and masking logs should be considered together. Interceptors themselves can be implemented with a fair degree of flexibility and have a wide range of uses — authorization, transformation, auditing — so I'd like to continue making use of them in scenarios where they're needed!
I hope this article is at least somewhat helpful. Thank you for reading to the end!
Addendum: Request Body Inspection and Transformation Are Also Possible
REQUEST interceptors can also inspect and transform the request body. The JSON-RPC request comes in as parsed JSON in the event's gatewayRequest.body, so you can extract the method, tool name, and argument contents for use in decision-making (the demo in the main text also checked the method in body to target only tools/call). Based on the decision, you can branch into: pass through as-is, transform and pass through, or short-circuit with a transformedGatewayResponse. When transforming, the content you put in transformedGatewayRequest.body and return is forwarded to the target as-is.
As a test, I'll swap in an interceptor that rewrites tools/call's arguments.name.
import json
def lambda_handler(event, context):
body = event.get("mcp", {}).get("gatewayRequest", {}).get("body", {})
if body.get("method") == "tools/call":
args = body.get("params", {}).get("arguments", {})
if "name" in args:
args["name"] = args["name"] + " (rewritten by interceptor)"
return {
"interceptorOutputVersion": "1.0",
"mcp": {"transformedGatewayRequest": {"body": body}},
}
The client calls with name set to Jinno.
{
"jsonrpc": "2.0",
"id": 30,
"result": {
"isError": false,
"content": [
{"type": "text", "text": "{\"greeting\":\"Hello, Jinno (rewritten by interceptor)!\"}"}
]
}
}
Not the value the client sent, but the value rewritten by the interceptor arrived at the target Lambda! This can be used for injecting context into arguments or masking specific fields.
Note that httpMethod and path are read-only and cannot be changed. Also, for HTTP targets (AgentCore Runtime, etc.), the body is passed as a base64-encoded string, so you need to decode it before processing.