IaC AWS

Terraform Module: AWS API Gateway (HTTP) — low-latency HTTP APIs with built-in JWT auth

Quick take — A production-ready Terraform module for AWS API Gateway HTTP APIs (apigatewayv2): routes, integrations, JWT authorizers, stages with throttling, and access logging — wired for hashicorp/aws ~> 5.0. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.

Quickstart (copy-paste)

Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):

provider "aws" {
  region = "us-east-1"
}

module "api_gateway_http" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-api-gateway-http?ref=v1.0.0"

  name         = "..."  # Name of the HTTP API; also names the authorizer and log…
  integrations = {}     # Backend integrations keyed by name; supports `AWS_PROXY…
  routes       = {}     # Routes keyed by route key (`"GET /items"`), each pointi…
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

AWS API Gateway HTTP APIs (the apigatewayv2 family) are the leaner, cheaper successor to REST APIs. They were designed for the common case: front a Lambda function, an Application Load Balancer, or any HTTP backend, add JWT-based authorization, and ship. Compared with REST (apigateway) APIs, HTTP APIs cost roughly 70% less per million requests, add lower latency, and natively support JWT authorizers and automatic CORS — at the price of dropping some advanced features like request/response transformations and API keys/usage plans.

The trouble is that a single working HTTP API is rarely one resource. It is an aws_apigatewayv2_api, plus one aws_apigatewayv2_integration per backend, one aws_apigatewayv2_route per METHOD /path, an aws_apigatewayv2_stage (with throttling and access logs), a CloudWatch log group, and — if you front Lambda — an aws_lambda_permission so the gateway is allowed to invoke it. Hand-rolling that for every service leads to copy-paste drift: one team forgets access logging, another hardcodes $default throttling, a third leaves CORS wide open.

This module wraps aws_apigatewayv2_api and its satellites behind a small, opinionated variable surface. You declare the protocol, a map of routes, an optional JWT authorizer, CORS, and throttling — and you get back a deployed HTTP API with a usable invoke URL, sane defaults, and consistent tagging across every environment.

When to use it

Reach for this module when:

Prefer the REST API (aws_api_gateway_rest_api) module instead when you need API keys and usage plans, request/response mapping templates, AWS WAF on the API directly (HTTP APIs require a custom-domain/CloudFront fronting for WAF), edge-optimized endpoints, or fine-grained per-method caching.

Module structure

terraform-module-aws-api-gateway-http/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

main.tf

locals {
  # Normalize the optional CORS block: only emit a cors_configuration
  # when at least one CORS field is actually set.
  cors_enabled = var.cors_configuration != null

  # Build the access-log format once so the stage stays readable.
  access_log_format = jsonencode({
    requestId               = "$context.requestId"
    ip                      = "$context.identity.sourceIp"
    requestTime             = "$context.requestTime"
    httpMethod              = "$context.httpMethod"
    routeKey                = "$context.routeKey"
    status                  = "$context.status"
    protocol                = "$context.protocol"
    responseLength          = "$context.responseLength"
    integrationErrorMessage = "$context.integrationErrorMessage"
    integrationLatency      = "$context.integrationLatency"
    responseLatency         = "$context.responseLatency"
  })
}

# ---------------------------------------------------------------------------
# The HTTP API
# ---------------------------------------------------------------------------
resource "aws_apigatewayv2_api" "this" {
  name          = var.name
  protocol_type = "HTTP"
  description   = var.description

  # Disable the default https://{api-id}.execute-api.{region}.amazonaws.com
  # endpoint when you front the API with a custom domain.
  disable_execute_api_endpoint = var.disable_execute_api_endpoint

  dynamic "cors_configuration" {
    for_each = local.cors_enabled ? [var.cors_configuration] : []
    content {
      allow_origins     = cors_configuration.value.allow_origins
      allow_methods     = cors_configuration.value.allow_methods
      allow_headers     = cors_configuration.value.allow_headers
      expose_headers    = cors_configuration.value.expose_headers
      allow_credentials = cors_configuration.value.allow_credentials
      max_age           = cors_configuration.value.max_age
    }
  }

  tags = var.tags
}

# ---------------------------------------------------------------------------
# Optional JWT authorizer (Cognito / Auth0 / Okta / any OIDC issuer)
# ---------------------------------------------------------------------------
resource "aws_apigatewayv2_authorizer" "jwt" {
  count = var.jwt_authorizer != null ? 1 : 0

  api_id           = aws_apigatewayv2_api.this.id
  authorizer_type  = "JWT"
  identity_sources = ["$request.header.Authorization"]
  name             = "${var.name}-jwt"

  jwt_configuration {
    audience = var.jwt_authorizer.audience
    issuer   = var.jwt_authorizer.issuer
  }
}

# ---------------------------------------------------------------------------
# Integrations — one per backend, keyed by the integration name
# ---------------------------------------------------------------------------
resource "aws_apigatewayv2_integration" "this" {
  for_each = var.integrations

  api_id                 = aws_apigatewayv2_api.this.id
  integration_type       = each.value.integration_type
  integration_uri        = each.value.integration_uri
  integration_method     = each.value.integration_method
  connection_type        = each.value.connection_type
  connection_id          = each.value.connection_id
  payload_format_version = each.value.payload_format_version
  timeout_milliseconds   = each.value.timeout_milliseconds
}

# ---------------------------------------------------------------------------
# Routes — keyed by route key ("GET /items", "POST /orders", "$default")
# ---------------------------------------------------------------------------
resource "aws_apigatewayv2_route" "this" {
  for_each = var.routes

  api_id    = aws_apigatewayv2_api.this.id
  route_key = each.key
  target    = "integrations/${aws_apigatewayv2_integration.this[each.value.integration_key].id}"

  # Attach the JWT authorizer only when the route opts in AND one exists.
  authorization_type = (
    each.value.authorized && var.jwt_authorizer != null ? "JWT" : "NONE"
  )
  authorizer_id = (
    each.value.authorized && var.jwt_authorizer != null
    ? aws_apigatewayv2_authorizer.jwt[0].id
    : null
  )
  authorization_scopes = each.value.authorized ? each.value.scopes : null
}

# ---------------------------------------------------------------------------
# Access-log group for the stage
# ---------------------------------------------------------------------------
resource "aws_cloudwatch_log_group" "access" {
  name              = "/aws/apigateway/${var.name}/${var.stage_name}"
  retention_in_days = var.log_retention_in_days
  kms_key_id        = var.log_kms_key_arn
  tags              = var.tags
}

# ---------------------------------------------------------------------------
# Stage — throttling, auto-deploy, access logging
# ---------------------------------------------------------------------------
resource "aws_apigatewayv2_stage" "this" {
  api_id      = aws_apigatewayv2_api.this.id
  name        = var.stage_name
  auto_deploy = true

  default_route_settings {
    throttling_burst_limit   = var.throttling_burst_limit
    throttling_rate_limit    = var.throttling_rate_limit
    detailed_metrics_enabled = var.detailed_metrics_enabled
  }

  access_log_settings {
    destination_arn = aws_cloudwatch_log_group.access.arn
    format          = local.access_log_format
  }

  tags = var.tags
}

# ---------------------------------------------------------------------------
# Allow API Gateway to invoke any AWS_PROXY (Lambda) integration
# ---------------------------------------------------------------------------
resource "aws_lambda_permission" "this" {
  for_each = {
    for k, v in var.integrations : k => v
    if v.integration_type == "AWS_PROXY" && v.lambda_function_name != null
  }

  statement_id  = "AllowAPIGatewayInvoke-${each.key}"
  action        = "lambda:InvokeFunction"
  function_name = each.value.lambda_function_name
  principal     = "apigateway.amazonaws.com"

  # Scope the permission to this API so an ARN leak can't be replayed elsewhere.
  source_arn = "${aws_apigatewayv2_api.this.execution_arn}/*/*"
}

variables.tf

variable "name" {
  description = "Name of the HTTP API. Used to name the API, authorizer, and log group."
  type        = string

  validation {
    condition     = can(regex("^[a-zA-Z0-9-_]{1,128}$", var.name))
    error_message = "name must be 1-128 chars: letters, digits, hyphens, underscores."
  }
}

variable "description" {
  description = "Description applied to the HTTP API."
  type        = string
  default     = null
}

variable "stage_name" {
  description = "Stage name to deploy (e.g. 'prod', 'v1'). Use '$default' for the implicit stage."
  type        = string
  default     = "$default"
}

variable "disable_execute_api_endpoint" {
  description = "Disable the default execute-api endpoint (set true when fronting with a custom domain)."
  type        = bool
  default     = false
}

variable "integrations" {
  description = <<-EOT
    Map of backend integrations keyed by a stable integration name (referenced from `routes`).
    For Lambda, set integration_type = "AWS_PROXY", integration_uri = the function invoke ARN,
    and lambda_function_name so the module can create the invoke permission.
  EOT
  type = map(object({
    integration_type       = string
    integration_uri        = string
    integration_method     = optional(string)
    connection_type        = optional(string, "INTERNET")
    connection_id          = optional(string)
    payload_format_version = optional(string, "2.0")
    timeout_milliseconds   = optional(number, 30000)
    lambda_function_name   = optional(string)
  }))

  validation {
    condition = alltrue([
      for k, v in var.integrations :
      contains(["AWS_PROXY", "HTTP_PROXY"], v.integration_type)
    ])
    error_message = "integration_type must be AWS_PROXY or HTTP_PROXY for HTTP APIs."
  }

  validation {
    condition = alltrue([
      for k, v in var.integrations :
      v.timeout_milliseconds >= 50 && v.timeout_milliseconds <= 30000
    ])
    error_message = "timeout_milliseconds must be between 50 and 30000 for HTTP APIs."
  }
}

variable "routes" {
  description = <<-EOT
    Map of routes keyed by route key, e.g. "GET /items", "POST /orders", "$default".
    Each route points at an integration via integration_key and may require JWT auth.
  EOT
  type = map(object({
    integration_key = string
    authorized      = optional(bool, false)
    scopes          = optional(list(string), [])
  }))

  validation {
    condition     = length(var.routes) > 0
    error_message = "At least one route is required."
  }
}

variable "jwt_authorizer" {
  description = "Optional JWT authorizer. issuer is the OIDC issuer URL; audience is the list of accepted audiences/client IDs."
  type = object({
    issuer   = string
    audience = list(string)
  })
  default = null
}

variable "cors_configuration" {
  description = "Optional CORS configuration for the API. Leave null to disable CORS."
  type = object({
    allow_origins     = optional(list(string), [])
    allow_methods     = optional(list(string), [])
    allow_headers     = optional(list(string), [])
    expose_headers    = optional(list(string), [])
    allow_credentials = optional(bool, false)
    max_age           = optional(number, 0)
  })
  default = null
}

variable "throttling_burst_limit" {
  description = "Default route throttling burst limit for the stage."
  type        = number
  default     = 500
}

variable "throttling_rate_limit" {
  description = "Default route throttling steady-state rate limit (requests/sec) for the stage."
  type        = number
  default     = 1000
}

variable "detailed_metrics_enabled" {
  description = "Enable detailed (per-route) CloudWatch metrics on the stage."
  type        = bool
  default     = true
}

variable "log_retention_in_days" {
  description = "Retention for the access-log CloudWatch log group."
  type        = number
  default     = 30

  validation {
    condition = contains(
      [1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1096, 1827, 2192, 2557, 2922, 3288, 3653],
      var.log_retention_in_days
    )
    error_message = "log_retention_in_days must be a valid CloudWatch retention value."
  }
}

variable "log_kms_key_arn" {
  description = "Optional KMS key ARN to encrypt the access-log group. Null uses the default CloudWatch encryption."
  type        = string
  default     = null
}

variable "tags" {
  description = "Tags applied to all taggable resources created by the module."
  type        = map(string)
  default     = {}
}

outputs.tf

output "api_id" {
  description = "The identifier of the HTTP API."
  value       = aws_apigatewayv2_api.this.id
}

output "api_name" {
  description = "The name of the HTTP API."
  value       = aws_apigatewayv2_api.this.name
}

output "api_arn" {
  description = "The ARN of the HTTP API."
  value       = aws_apigatewayv2_api.this.arn
}

output "execution_arn" {
  description = "Execution ARN — use as the source_arn base for Lambda/IAM permissions."
  value       = aws_apigatewayv2_api.this.execution_arn
}

output "api_endpoint" {
  description = "The default https endpoint of the HTTP API (https://{api-id}.execute-api.{region}.amazonaws.com)."
  value       = aws_apigatewayv2_api.this.api_endpoint
}

output "invoke_url" {
  description = "Fully-qualified invoke URL including the stage path."
  value       = aws_apigatewayv2_stage.this.invoke_url
}

output "stage_id" {
  description = "The identifier of the deployed stage."
  value       = aws_apigatewayv2_stage.this.id
}

output "authorizer_id" {
  description = "The JWT authorizer ID, or null when no authorizer is configured."
  value       = try(aws_apigatewayv2_authorizer.jwt[0].id, null)
}

output "log_group_name" {
  description = "Name of the CloudWatch access-log group for the stage."
  value       = aws_cloudwatch_log_group.access.name
}

How to use it

# An existing Lambda we want to front with the HTTP API.
data "aws_lambda_function" "orders" {
  function_name = "orders-service"
}

module "api_gateway_http_orders" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-api-gateway-http?ref=v1.0.0"

  name        = "orders-api"
  description = "Public HTTP API for the orders microservice"
  stage_name  = "prod"

  integrations = {
    orders_lambda = {
      integration_type     = "AWS_PROXY"
      integration_uri      = data.aws_lambda_function.orders.invoke_arn
      lambda_function_name = data.aws_lambda_function.orders.function_name
      payload_format_version = "2.0"
    }
  }

  routes = {
    "GET /orders"       = { integration_key = "orders_lambda", authorized = true, scopes = ["orders.read"] }
    "POST /orders"      = { integration_key = "orders_lambda", authorized = true, scopes = ["orders.write"] }
    "GET /orders/{id}"  = { integration_key = "orders_lambda", authorized = true, scopes = ["orders.read"] }
    "GET /health"       = { integration_key = "orders_lambda", authorized = false }
  }

  # Validate Cognito-issued JWTs.
  jwt_authorizer = {
    issuer   = "https://cognito-idp.ap-south-1.amazonaws.com/ap-south-1_AbCdEf123"
    audience = ["4f9k2m7p1q8r3s6t0u5v9w2x"]
  }

  cors_configuration = {
    allow_origins = ["https://app.kloudvin.com"]
    allow_methods = ["GET", "POST", "OPTIONS"]
    allow_headers = ["authorization", "content-type"]
    max_age       = 300
  }

  throttling_burst_limit = 1000
  throttling_rate_limit  = 2000
  log_retention_in_days  = 90

  tags = {
    Environment = "prod"
    Team        = "commerce"
    ManagedBy   = "Terraform"
  }
}

# Downstream reference: publish the invoke URL to SSM so the web app reads it.
resource "aws_ssm_parameter" "orders_api_url" {
  name  = "/commerce/orders-api/invoke-url"
  type  = "String"
  value = module.api_gateway_http_orders.invoke_url
}

With Terragrunt

Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.

1. Root configlive/terragrunt.hcl (inherited by every module):

remote_state {
  backend = "s3"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...s3 state bucket/container + key per path...
  }
}

2. Module configlive/prod/api_gateway_http/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-api-gateway-http?ref=v1.0.0"
}

inputs = {
  name = "..."
  integrations = {}
  routes = {}
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/api_gateway_http && terragrunt apply        # this module
terragrunt run-all apply                      # every module under live/prod

Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.

Inputs

Name Type Default Required Description
name string Yes Name of the HTTP API; also names the authorizer and log group.
description string null No Description applied to the HTTP API.
stage_name string "$default" No Stage to deploy (e.g. prod, v1), or $default for the implicit stage.
disable_execute_api_endpoint bool false No Disable the default execute-api endpoint when fronting with a custom domain.
integrations map(object) Yes Backend integrations keyed by name; supports AWS_PROXY (Lambda) and HTTP_PROXY (ALB/HTTP).
routes map(object) Yes Routes keyed by route key ("GET /items"), each pointing at an integration_key.
jwt_authorizer object null No Optional JWT authorizer with OIDC issuer and accepted audience list.
cors_configuration object null No Optional declarative CORS config; null disables CORS.
throttling_burst_limit number 500 No Default-route throttling burst limit for the stage.
throttling_rate_limit number 1000 No Default-route steady-state rate limit (req/s) for the stage.
detailed_metrics_enabled bool true No Enable per-route CloudWatch metrics on the stage.
log_retention_in_days number 30 No Retention for the access-log group (valid CloudWatch value).
log_kms_key_arn string null No KMS key ARN to encrypt the access-log group.
tags map(string) {} No Tags applied to all taggable resources.

Outputs

Name Description
api_id The identifier of the HTTP API.
api_name The name of the HTTP API.
api_arn The ARN of the HTTP API.
execution_arn Execution ARN — base for Lambda/IAM source_arn permissions.
api_endpoint Default https endpoint (https://{api-id}.execute-api.{region}.amazonaws.com).
invoke_url Fully-qualified invoke URL including the stage path.
stage_id The identifier of the deployed stage.
authorizer_id The JWT authorizer ID, or null when none is configured.
log_group_name Name of the CloudWatch access-log group for the stage.

Enterprise scenario

A retail platform runs roughly 40 microservices, each fronted by its own HTTP API. The platform team consumes this module from a shared Terraform monorepo, passing each service a route map and a common jwt_authorizer block pointing at the corporate Cognito user pool, so every API validates the same OIDC tokens and scopes. Because access logging, 2000 req/s throttling, and Team/Environment tags are baked into the module defaults, a new service goes from PR to production-grade HTTP front door in minutes — and the SecOps team can query one consistent JSON access-log format across all 40 APIs in CloudWatch Logs Insights.

Best practices

TerraformAWSAPI Gateway (HTTP)ModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading