[Tip] Tried passing custom headers to the AgentCore Gateway interceptor

[Tip] Tried passing custom headers to the AgentCore Gateway interceptor

I verified whether custom HTTP headers attached by MCP clients reach the interceptor of Amazon Bedrock AgentCore Gateway!
2026.07.09

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.

https://dev.classmethod.jp/articles/bedrock-agentcore-gateway-request-interceptor-authorization/

https://dev.classmethod.jp/articles/amazon-bedrock-agentcore-gateway-response-interceptor/

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.headers when passRequestHeaders is set to true in the interceptor configuration
  • When passRequestHeaders is not configured, in this verification headers was 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 passRequestHeaders also 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 passRequestHeaders field is set to true. Be careful when using this field as request headers can contain sensitive information such as authentication tokens and credentials.

https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway-interceptors.html

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
Creating Cognito resources
# 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.

interceptor/lambda_function.py
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:

https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway-interceptors-types.html

Once the code is ready, I'll create an execution role and deploy the Lambda function.

Deploying the interceptor Lambda
# 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.

Creating the Gateway execution role
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.

create_gateway.py
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
lambda_function.py
def lambda_handler(event, context):
    name = event.get('name', 'world')
    return {'greeting': f'Hello, {name}!'}
Deploying the tool Lambda
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
create_target.py
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.

Execution command
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"}}}'
Execution result
{
  "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.

Event received by the interceptor (excerpt, token masked)
{
  "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.

Event when passRequestHeaders is not set (excerpt)
{
  "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.

interceptor/lambda_function.py
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:

Execution result (X-Dept-Id: dept-b)
{"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.

interceptor/lambda_function.py
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.

Execution result
{
  "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.

Share this article