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

  1. SSH into an EC2 inside the VPC
  2. 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

Part 2 coming out soon on: DynamoDB, PrivateLink cross-VPC, or IAM auth with SigV4!

Related Posts