Developer
— 10 min read
Using API Gateway to deliver egress IPs with AWS and Terraform

Airkit IP-Ranges
Here at Airkit, we provide an HTTP(s) endpoint (ip-ranges.airkit.com) that produces a JSON list of all possible egress IP addresses that traffic could use when establishing a connection between Airkit and any other external service.
Hopefully one day the internet as a whole can move past deny/allow lists to full zero-trust security models. Until that day comes, we built this to help provide both granularity and dynamism to our customers, as they use this information to help in managing their own firewall deny/allow rules. As a self-service endpoint, our customers can access this always-up-to-date information whenever they want.With the continued growth of our services, the incorporation of new geographical locations, and use of modern high-availability upgrade procedures, the chances of this list remaining unchanged becomes smaller. Where in years past this information would have been kept up to date by hand, modern firewalls are often capable of directly using this information to implement changes.

Technically speaking, the API we created is derived from a hello-world API, that we then wrapped with a few extra bonus features (caching, http-redirect, etc). Below we will step through the design and implementation, with the understanding that the code snippets provided are primarily the deviations from an otherwise-standard hello-world example.
Top-Level Design
To do this in AWS, we built an API that has a CDN in front and is backed by a Lambda function. In diagram form, the service we created looks like:

- Public internet traffic hits the DNS
A
record forip-ranges.airkit.com
(Route53) - The
A
Record points to the CloudFront distribution (ex:a1b2c3d4e5f6g7.cloudfront.net
) - The CloudFront distribution is responsible for:
- Redirecting
HTTP
traffic toHTTPS
- Edge caching
- Forwarding traffic to the API Gateway endpoint
(ex:https://z1y2x3w4v5.execute-api.us-west-2.amazonaws.com/ip_ranges
)
- Redirecting
- The API Gateway then executes the
egress-ip-ranges-api
Lambda Function - The Lambda function does the heavy-lifting:
- Uses a limited IAM Role specifically for describing our NAT Gateways
- Loops through all Airkit deployment regions
- Coalesces and returns NAT Gateway IP addresses from every region
API G(ateway) + Lambda
The CDN & DNS are definitely usability adds; However, the true functionality is provided by this powerhouse APIG + Lambda combination.
Take a look at the following NodeJS that we use in our Lambda:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
var AWS = require('aws-sdk'); var EC2 = new AWS.EC2({"region": "us-west-2"}); var params = {Filter: [{Name: "tag:Name", Values:["*proxy*"]}]}; var regions = ["us-west-2", "eu-central-1", "ap-southeast-2"]; module.exports.handler = async (event) => { var results = {"egress": new Array()}; var statusCode = 200; await Promise.all(regions.map(async (region) => { await new AWS.EC2({"region": region}) .describeNatGateways(params, function(error, data) { if (error) { console.error(error); // an error occurred statusCode = 500; } else { data.NatGateways.forEach(NatGateway => { NatGateway.NatGatewayAddresses.forEach(Interface => { var unique = new Set(results.egress).add(Interface.PublicIp+"/32"); results.egress = [...unique]; }); }); } }).promise(); })); if (statusCode != 200) { delete results.egress; } return { statusCode: statusCode, headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(results) }; };
- The EC2 Service Interface is configured to connect to specific
regions
. This makes the individual requests in parallel, as they eventually populate theegress
array inresults
. - The contents of
params
includes a Filter; Specifically filtering by AWS tags containingproxy
. This narrows the returned NAT Gateway list to only contain the specific NAT Gateways for our egress HTTP proxy. NOTE: Filtering by tag requires the tags to have been setup previously. - Errors are logged if they occur, and otherwise the
data
object is looped over, using a Set()+Array() to provide a unique list of the filtered NAT Gateways IP addresses - We tie this all together with a HTTP-specific return, with
statusCode
,headers
, andbody
set to return theresult
object we created that contains the list of addresses.

IAM Role: Describe NAT Gateways
Let’s start by giving the above Lambda function permissions to carry out the AWS API functions we set it up for; namely describing NAT Gateways:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
data "aws_iam_policy_document" "assume_role" { statement { effect = "Allow" actions = [ "sts:AssumeRole" ] principals { type = "Service" identifiers = [ "lambda.amazonaws.com" ] } } } resource "aws_iam_role" "lambda_exec" { name = "ip_ranges_api_serverless_lambda" assume_role_policy = data.aws_iam_policy_document.assume_role.json } data "aws_iam_policy_document" "access" { statement { effect = "Allow" actions = [ "ec2:describeNatGateways" ] resources = [ "*" ] } statement { effect = "Allow" actions = [ "logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents" ] resources = [ "*" ] } } resource "aws_iam_role_policy" "access" { name = "ip_ranges_api_access_policy" role = aws_iam_role.lambda_exec.id policy = data.aws_iam_policy_document.access.json }
Breaking down the above terraform, we have:
- An
assume_role
policy document that is actually a Trust Policy defined to allow Lambda to use the Role permissions we are about to define. This is done by giving thelambda.amazonaws.com
Service Principal permission to call thests:AssumeRole
action. - The
assume_role
policy document must then be attached as theassume_role_policy
for the IAM Role that our Lambda application will use to execute with - An
access
policy document that is where we define 2 statements to be used by the core functionality of our Lambda application code:- Allowing the
ec2:describeNatGateways
action to be run against all accounts/regions - Allowing 3 different Logs actions to let us log our console output to CloudWatch
- Allowing the
- This
access
policy document must then also be attached as an additional policy to the same IAM Role
HTTPS: Certificate Validation
In order to share the same certificate between our Lambda (running in us-west-2
) and CloudFront (running in us-east-1
) we need to make sure the certificate exists in both locations in ACM.
To do this, we run the following:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73
resource "aws_acm_certificate" "us_west_2" { provider = aws.us_west_2 domain_name = var.domain_name subject_alternative_names = var.subject_alternative_names validation_method="DNS" lifecycle { create_before_destroy = true } } resource "aws_acm_certificate" "us_east_1" { provider = aws.us_east_1 domain_name = var.domain_name subject_alternative_names = var.subject_alternative_names validation_method="DNS" lifecycle { create_before_destroy = true } } resource "aws_route53_record" "certificate_validation" { for_each = merge( { for dvo in aws_acm_certificate.us_east_1.domain_validation_options : dvo.domain_name => { name = dvo.resource_record_name record = dvo.resource_record_value type = dvo.resource_record_type zone_id = data.aws_route53_zone.domain.zone_id } }, { for dvo in aws_acm_certificate.us_west_2.domain_validation_options : dvo.domain_name => { name = dvo.resource_record_name record = dvo.resource_record_value type = dvo.resource_record_type zone_id = data.aws_route53_zone.domain.zone_id } } ) allow_overwrite = true name = each.value.name records = [each.value.record] ttl = 30 type = each.value.type zone_id = each.value.zone_id } resource "aws_acm_certificate_validation" "us_west_2" { provider = aws.us_west_2 certificate_arn = aws_acm_certificate.us_west_2.arn validation_record_fqdns = [ for record in aws_route53_record.certificate_validation : record.fqdn ] timeouts { create = "10m" } } resource "aws_acm_certificate_validation" "us_east_1" { provider = aws.us_east_1 certificate_arn = aws_acm_certificate.us_east_1.arn validation_record_fqdns = [ for record in aws_route53_record.certificate_validation : record.fqdn ] timeouts { create = "10m" } } resource "aws_apigatewayv2_domain_name" "ip_ranges" { for_each = toset(flatten([var.domain_name, var.subject_alternative_names])) domain_name = each.value domain_name_configuration { certificate_arn = aws_acm_certificate.us_west_2.arn endpoint_type = "REGIONAL" security_policy = "TLS_1_2" } }
The important takeaways from the above:
- We create the 2 different (but identical) certificates because AWS services are dependent on having regionally-colocated certificates.
- By using
DNS
validation, we can auto-generate the required Route53 records by looping over the combineddomain_validation_options
from the certificates. - We use a certificate validation resource (aka:
aws_acm_certificate_validation
) specifically to wait for theDNS
validation to succeed, and then use the validated certificate inside ouraws_apigatewayv2_domain_name
(the API Gateway will use the certificate deployed inus-west-2
while the CloudFront Distribution will use the certificated deployed inus-east-1
)
API Gateway: HTTP API
With AWS API Gateway (v2 because we are creating an HTTP API) one would typically define some endpoint route_keys
that would execute specific APIs. However in the Airkit case, we want to access the API without needing an API route.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
resource "aws_apigatewayv2_stage" "lambda" { api_id = aws_apigatewayv2_api.lambda.id name = "ip_ranges" auto_deploy = true default_route_settings { throttling_burst_limit = 1 throttling_rate_limit = 1 } } resource "aws_apigatewayv2_route" "ip_ranges" { api_id = aws_apigatewayv2_api.lambda.id route_key = "$default" target = "integrations/${aws_apigatewayv2_integration.ip_ranges.id}" }
A few key callouts from the above:
- We specifically set the default route settings (aka:
throttling_burst_limit
andthrottling_rate_limit
) to a low value to prevent our API from potentially causing runaway Lambda costs. - Because we created an HTTP API (API Gateway v2) we use the special
$default
route key that is available to us. Using this allows the base URL to be hit as the endpoint for our API.
What we should have defined at this point is a functioning API Gateway endpoint URL that we can access in order to call our Lambda.

CloudFront: Cache & HTTP Redirect
AWS CloudFront is typically deployed as an edge-cache to be backed by some form of S3 bucket. In our case however, we want the backend to be the API Gateway we just created.
Additionally, because we can perform HTTP-to-HTTPS redirecting in AWS CloudFront, we will use that feature to compensate for the missing HTTP endpoint in the API Gateway.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
resource "aws_cloudfront_distribution" "http_redirect" { provider = aws.us_east_1 enabled = true origin { domain_name = regex("[htps]*://(.*.com)(.*)", aws_apigatewayv2_stage.lambda.invoke_url)[0] origin_path = regex("[htps]*://(.*.com)(.*)", aws_apigatewayv2_stage.lambda.invoke_url)[1] origin_id = replace(var.domain_name, "/[^a-zA-Z0-9]/", "_") custom_origin_config { http_port = "80" https_port = "443" origin_protocol_policy = "https-only" origin_ssl_protocols = ["TLSv1.2"] } } default_cache_behavior { allowed_methods = ["GET", "HEAD"] cached_methods = ["GET", "HEAD"] target_origin_id = replace(var.domain_name, "/[^a-zA-Z0-9]/", "_") viewer_protocol_policy = "redirect-to-https" forwarded_values { query_string = true cookies { forward = "none" } } } restrictions { geo_restriction { restriction_type = "none" } } viewer_certificate { acm_certificate_arn = aws_acm_certificate.us_east_1.arn ssl_support_method = "sni-only" minimum_protocol_version = "TLSv1.2_2021" } aliases = flatten([var.domain_name, var.subject_alternative_names]) }

Breaking down the http_redirect
distribution, there are a few key elements that make this work:
- The
us-east-1
provider is because CloudFront in conjunction with ACM is locked to that region - Regex is used to parse out the
domain_name
and theorigin_path
from the URL endpoint provided by API Gateway. (You can also view this “Invoke URL” in the AWS Console) - The
custom_origin_config
is required to prevent CloudFront from assuming an S3 backend - We pass in the
var.domain_name
(ex: prod.airkit.com) andvar.subject_alternative_names
(ex: Automate Digital Customer Experiences Faster ) as variables for use in both attaining SSL/TLS certificates from ACM, but also in naming ourorigin_id
(likewise thetarget_origin_id
later in the file), and providing CloudFront with distributionaliases
- The
viewer_protocol_policy
set toredirect-to-https
fixes our previously-missing handling of HTTP requests
Route53: Alias for CloudFront
The only step we are missing from our original top-level design is our DNS alias that would point ip-ranges.airkit.com and ip-ranges.prod.airkit.com to the same CloudFront endpoint.
1 2 3 4 5 6 7 8 9 10 11 12 13
resource "aws_route53_record" "ip_ranges" { for_each = toset(flatten([var.domain_name, var.subject_alternative_names])) name = aws_apigatewayv2_domain_name.ip_ranges[each.value].domain_name type = "A" zone_id = data.aws_route53_zone.domain.zone_id alias { name = aws_cloudfront_distribution.http_redirect.domain_name zone_id = aws_cloudfront_distribution.http_redirect.hosted_zone_id evaluate_target_health = false } }
Again breaking it down:
- We loop over all Domains and SAN (Subject Alternative Names), creating an alias for each within the same Route53 HostedZone, namely
domain
(In Airkit’s particular case the ZoneID is always the same for any given set of Domain + SAN) - We point the alias destination to the CloudFront distribution we just set up. Completing both the
HTTPS
andHTTP
paths from our URL to our Lambda function.
Summary
Airkit’s entire list of egress IP addresses is available now at ip-ranges.airkit.com. This is not a substitute for authentication, proper access controls and AI-driven attack mitigation. However, it can provide an additional level of granularity to an existing security stature, hopefully driving an experience that both we and our customers can love.
After all, empowering our customers to build experiences that people love, is what we do.