AWS API Gateway
API Gateway terminates mutual TLS on a custom domain and passes the client certificate to mt-enforcer running as a Lambda authorizer. The enforcer re-validates it against the signed policy and authorizes the route and method of every request.
Prepare
mt-enforcer pair --api https://api.mutualtrust.io --state ./mt-state
mt-enforcer bundle --state ./mt-state --out client-ca.pem
cd services/enforcer && GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -trimpath -o bootstrap ./cmd/mt-enforcer && zip mt-enforcer-lambda.zip bootstrapThe pairing is stored in Secrets Manager and read at cold start through the AWS Parameters and Secrets Lambda Extension (MT_ENFORCER_STATE_SECRET); MT_ENFORCER_STATE_JSON works as well.
Terraform (HTTP API)
# Mutual Trust with Amazon API Gateway (HTTP API) and a Lambda authorizer.
#
# API Gateway terminates mutual TLS on a custom domain against a truststore in
# S3 and passes the client certificate to the authorizer
# (requestContext.authentication.clientCert). mt-enforcer, running as the
# authorizer (provided.al2023, arm64), re-validates it against the signed policy
# manifest and answers with a simple response. The verified identity reaches the
# backend through parameter mapping. The default execute-api endpoint is
# disabled so every request goes through the mTLS domain.
#
# Before applying:
# mt-enforcer pair --api https://api.mutualtrust.io --state ./mt-state
# mt-enforcer bundle --state ./mt-state --out client-ca.pem # truststore content
# GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o bootstrap ./cmd/mt-enforcer && zip mt-enforcer-lambda.zip bootstrap
# Re-run `mt-enforcer bundle` and apply again when the space adds or rotates an issuer.
terraform {
required_version = ">= 1.6"
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.80" }
}
}
variable "domain_name" { type = string } # e.g. orders-api.example.com
variable "certificate_arn" { type = string } # ACM certificate for domain_name
variable "truststore_bucket" { type = string }
variable "backend_url" { type = string } # e.g. https://orders.internal.example.com/{proxy}
variable "state_dir" {
type = string
default = "./mt-state" # written by `mt-enforcer pair`
}
variable "secrets_extension_layer_arn" { type = string } # AWS-Parameters-and-Secrets-Lambda-Extension-Arm64 for your region
# Pairing (gateway credential, pinned policy key, gateway signing key) in Secrets Manager.
resource "aws_secretsmanager_secret" "pairing" {
name = "mutualtrust/${var.domain_name}/enforcer"
}
resource "aws_secretsmanager_secret_version" "pairing" {
secret_id = aws_secretsmanager_secret.pairing.id
secret_string = file("${var.state_dir}/gateway-state.json")
}
resource "aws_s3_object" "truststore" {
bucket = var.truststore_bucket
key = "mutualtrust/${var.domain_name}/client-ca.pem"
source = "${path.module}/client-ca.pem"
etag = filemd5("${path.module}/client-ca.pem")
}
data "aws_iam_policy_document" "assume" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["lambda.amazonaws.com"]
}
}
}
resource "aws_iam_role" "authorizer" {
name = "mt-enforcer-authorizer"
assume_role_policy = data.aws_iam_policy_document.assume.json
}
resource "aws_iam_role_policy_attachment" "logs" {
role = aws_iam_role.authorizer.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
resource "aws_iam_role_policy" "pairing" {
role = aws_iam_role.authorizer.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{ Effect = "Allow", Action = ["secretsmanager:GetSecretValue"], Resource = aws_secretsmanager_secret.pairing.arn }]
})
}
resource "aws_lambda_function" "authorizer" {
function_name = "mt-enforcer-authorizer"
role = aws_iam_role.authorizer.arn
runtime = "provided.al2023"
architectures = ["arm64"]
handler = "bootstrap"
filename = "${path.module}/mt-enforcer-lambda.zip"
source_code_hash = filebase64sha256("${path.module}/mt-enforcer-lambda.zip")
memory_size = 256
timeout = 5
layers = [var.secrets_extension_layer_arn]
environment {
variables = {
MT_ENFORCER_STATE_SECRET = aws_secretsmanager_secret.pairing.arn
MT_ENFORCER_CONFIG = "" # optional: path to a YAML file packaged in the zip
}
}
}
resource "aws_apigatewayv2_api" "api" {
name = "orders-api"
protocol_type = "HTTP"
disable_execute_api_endpoint = true # only the mTLS custom domain serves the API
}
resource "aws_apigatewayv2_authorizer" "mt" {
api_id = aws_apigatewayv2_api.api.id
name = "mt-enforcer"
authorizer_type = "REQUEST"
authorizer_uri = aws_lambda_function.authorizer.invoke_arn
authorizer_payload_format_version = "2.0"
enable_simple_responses = true
authorizer_result_ttl_in_seconds = 0 # decide every request (route and method are part of the decision)
}
resource "aws_lambda_permission" "apigw" {
statement_id = "AllowApiGatewayInvoke"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.authorizer.function_name
principal = "apigateway.amazonaws.com"
source_arn = "${aws_apigatewayv2_api.api.execution_arn}/*"
}
resource "aws_apigatewayv2_integration" "backend" {
api_id = aws_apigatewayv2_api.api.id
integration_type = "HTTP_PROXY"
integration_method = "ANY"
integration_uri = var.backend_url
payload_format_version = "1.0"
request_parameters = {
"overwrite:header.x-mt-principal" = "$context.authorizer.principal"
"overwrite:header.x-mt-grant" = "$context.authorizer.grant_id"
"overwrite:header.x-mt-request-id" = "$context.authorizer.request_id"
}
}
resource "aws_apigatewayv2_route" "all" {
api_id = aws_apigatewayv2_api.api.id
route_key = "ANY /{proxy+}"
target = "integrations/${aws_apigatewayv2_integration.backend.id}"
authorization_type = "CUSTOM"
authorizer_id = aws_apigatewayv2_authorizer.mt.id
}
resource "aws_apigatewayv2_stage" "default" {
api_id = aws_apigatewayv2_api.api.id
name = "$default"
auto_deploy = true
}
resource "aws_apigatewayv2_domain_name" "mtls" {
domain_name = var.domain_name
domain_name_configuration {
certificate_arn = var.certificate_arn
endpoint_type = "REGIONAL"
security_policy = "TLS_1_2"
}
mutual_tls_authentication {
truststore_uri = "s3://${var.truststore_bucket}/${aws_s3_object.truststore.key}"
truststore_version = aws_s3_object.truststore.version_id
}
}
resource "aws_apigatewayv2_api_mapping" "mtls" {
api_id = aws_apigatewayv2_api.api.id
domain_name = aws_apigatewayv2_domain_name.mtls.id
stage = aws_apigatewayv2_stage.default.id
}
output "regional_domain_name" {
value = aws_apigatewayv2_domain_name.mtls.domain_name_configuration[0].target_domain_name # CNAME target for domain_name
}Responses
| API type | Response | Backend receives |
|---|---|---|
| HTTP API, payload 2.0 | simple response: isAuthorized and context (principal, grant_id, request_id, reason, policy_revision) | overwrite:header.x-mt-principal from $context.authorizer.principal |
| HTTP API without simple responses | IAM policy (--response iam) | same context keys |
| REST API (REQUEST authorizer) | IAM policy for methodArn with the context | context.authorizer.principal in the integration request mapping |
Set the authorizer cache TTL to 0: the decision depends on the route and method. Disable the default execute-api endpoint so the API is only reachable through the mTLS domain.
Policy freshness in Lambda
The verified manifest and high-water mark are cached in the execution environment (under /tmp). Because a frozen environment runs no background work, the authorizer refreshes inline when the refresh interval has passed, keeps a valid cached manifest when the control plane is unreachable, and denies every request once the manifest is stale. Receipts are flushed when 20 are queued or every 5 seconds of activity.