Securing APIs in AWS: Private API Gateway + VPC Endpoint Deep Dive
2024-08-21
🔍 Overview
Private API Gateway allows you to expose REST APIs that are only accessible inside your VPC. No public internet access. This is ideal for internal microservices, backend systems, or APIs you want fully private.
Scenario
We'll implement a secure internal Employee Directory API using AWS Lambda, API Gateway (private), and VPC Interface Endpoints. The API will be accessible only inside the VPC.
🧱 Prerequisites
- AWS CLI or Terraform
- A VPC with private subnets
- curl or Postman
- Basic understanding of:
- API Gateway
- Lambda
- IAM
- VPC endpoints (interface)
🛠️ Architecture
[ EC2 in VPC ] ---> [ VPC Endpoint ] ---> [ Private API Gateway ] ---> [ Lambda (Employee Directory) ]
Only resources inside the VPC can reach the API through the interface endpoint.
🔧 Step-by-Step Deployment (Terraform)
1. Create VPC, Subnet, and Security Group
resource "aws_vpc" "main" { cidr_block = "10.0.0.0/16" enable_dns_support = true enable_dns_hostnames = true } resource "aws_subnet" "private" { vpc_id = aws_vpc.main.id cidr_block = "10.0.1.0/24" availability_zone = "us-east-1a" map_public_ip_on_launch = false } resource "aws_security_group" "vpce_sg" { name = "vpce-sg" vpc_id = aws_vpc.main.id ingress { from_port = 443 to_port = 443 protocol = "tcp" cidr_blocks = [aws_vpc.main.cidr_block] } egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } }
2. Deploy the Lambda Function
Use the Python code below and package it:
mkdir employee-directory && cd employee-directory vi index.py # lets use our mini lambda below zip employee_directory.zip index.py
import json
EMPLOYEES = {
"1001": {"id": "1001", "name": "Qais N", "role": "DevOps Engineer"},
"1002": {"id": "1002", "name": "Abz Ab", "role": "Backend Engineer"},
"1003": {"id": "1003", "name": "James John", "role": "Product Manager"},
}
def lambda_handler(event, context):
path = event.get("path", "")
method = event.get("httpMethod", "")
if method == "GET" and path.startswith("/employee/"):
emp_id = path.split("/")[-1]
emp = EMPLOYEES.get(emp_id)
if emp:
return {"statusCode": 200, "body": json.dumps(emp)}
return {"statusCode": 404, "body": json.dumps({"error": "Employee not found"})}
if method == "GET" and path == "/employee":
return {"statusCode": 200, "body": json.dumps(list(EMPLOYEES.values()))}
return {"statusCode": 400, "body": json.dumps({"error": "Unsupported operation"})}
resource "aws_iam_role" "lambda_exec" { name = "lambda_exec_role" assume_role_policy = jsonencode({ Version = "2012-10-17", Statement = [{ Action = "sts:AssumeRole", Principal = { Service = "lambda.amazonaws.com" }, Effect = "Allow", }], }) } resource "aws_lambda_function" "employee_directory" { filename = "employee_directory.zip" function_name = "employee_directory" role = aws_iam_role.lambda_exec.arn handler = "index.lambda_handler" runtime = "python3.9" source_code_hash = filebase64sha256("employee_directory.zip") }
3. Create Private API Gateway and Integration
resource "aws_api_gateway_rest_api" "private_api" { name = "employee-directory-api" endpoint_configuration { types = ["PRIVATE"] } } resource "aws_api_gateway_resource" "employee" { rest_api_id = aws_api_gateway_rest_api.private_api.id parent_id = aws_api_gateway_rest_api.private_api.root_resource_id path_part = "employee" } resource "aws_api_gateway_method" "get" { rest_api_id = aws_api_gateway_rest_api.private_api.id resource_id = aws_api_gateway_resource.employee.id http_method = "GET" authorization = "NONE" } resource "aws_api_gateway_integration" "lambda_integration" { rest_api_id = aws_api_gateway_rest_api.private_api.id resource_id = aws_api_gateway_resource.employee.id http_method = aws_api_gateway_method.get.http_method type = "AWS_PROXY" integration_http_method = "POST" uri = aws_lambda_function.employee_directory.invoke_arn }
4. Configure VPC Endpoint and Policy
resource "aws_vpc_endpoint" "api_gw" { vpc_id = aws_vpc.main.id service_name = "com.amazonaws.${var.region}.execute-api" vpc_endpoint_type = "Interface" subnet_ids = [aws_subnet.private.id] security_group_ids = [aws_security_group.vpce_sg.id] } resource "aws_api_gateway_deployment" "deployment" { rest_api_id = aws_api_gateway_rest_api.private_api.id stage_name = "prod" depends_on = [aws_api_gateway_integration.lambda_integration] } resource "aws_api_gateway_rest_api_policy" "restrict_to_vpce" { rest_api_id = aws_api_gateway_rest_api.private_api.id policy = jsonencode({ Version = "2012-10-17", Statement = [ { Effect = "Deny", Principal = "*", Action = "execute-api:Invoke", Resource = "*", Condition = { StringNotEquals = { "aws:SourceVpce" = aws_vpc_endpoint.api_gw.id } } } ] }) }
🧪 Test the Setup
- SSH into an EC2 inside the VPC
- Run:
curl https://<rest-api-id>.execute-api.<region>.amazonaws.com/prod/employee curl https://<rest-api-id>.execute-api.<region>.amazonaws.com/prod/employee/1002
✅ Should return valid responses inside the VPC (inside EC2) ❌ Should be blocked from public internet
📌 Summary
- Built a fully private Employee Directory API
- Integrated it with Lambda + API Gateway
- Made it VPC-only using Interface Endpoints
- Secured access with SourceVpce IAM policy
Great for internal-only microservices, B2B APIs, or compliance-sensitive systems.
🧼 Destroy with:
terraform destroy