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.

Documentation menu

Prepare

Pair, export the truststore, build the authorizer
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 bootstrap

The 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)

services/enforcer/integrations/aws/api-gateway/main.tf
# 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

Authorizer responses
API typeResponseBackend receives
HTTP API, payload 2.0simple 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 responsesIAM policy (--response iam)same context keys
REST API (REQUEST authorizer)IAM policy for methodArn with the contextcontext.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.