Cross Account API Calls via VPC Endpoint: A Debugging Log

03/08/2026

22

Key Takeaways

    I spent almost an entire day just trying to call an API from one AWS account to another through a VPC Endpoint. It was not because the task was hard, but because there were too many small details that could go wrong without anyone warning me in advance. I am writing this so the team does not have to retrace the same steps.

Context

The system runs across two AWS accounts:

  • Account A: where the ECS service runs and needs to call the API
  • Account B: where the backend team deploys a Private REST API Gateway

The requirement sounded simple: “Let ECS in Account A call the API in Account B without going out to the internet.”

The solution also seemed clear: create an Interface VPC Endpoint for execute-api in Account A, and the API Gateway in Account B would receive the request through it. AWS documentation covers it, diagrams exist, and it seemed like a 30 minute job.

Then I ran curl for the first time.

curl: (6) Could not resolve host

DNS was probably wrong. Time to fix it.

Second try.

curl: (28) Failed to connect to port 443 after 265337 ms: Couldn't connect to server

Timeout. Must be the Security Group. Fix it too.

Third try.

{"Message":"User: anonymous is not authorized ... with an explicit deny in a resource-based policy"}

403. Getting close! But why an explicit deny?

That was the moment I realized this setup had more layers than I expected, and each layer could fail in its own way.

Overall architecture

Before going through each error, here is the full flow:

Overall architecture

Key point: the VPCE acts as a private bridge between the two accounts. The request travels from ECS into the VPCE and then straight to the API Gateway, without going out to the internet and without passing through a NAT Gateway.

Setup, step by step

Step 1 (Account A): Create an Interface VPC Endpoint

Create an Interface type VPC Endpoint for the execute-api service:

VPC → Endpoints → Create Endpoint
  Service name      : com.amazonaws.ap-northeast-1.execute-api
  VPC               : VPC containing the ECS task
  Subnets           : subnet(s) containing the ECS task (must be in the same AZ)
  Enable private DNS: ON
  Security Group    : sg-vpce (create separately, see Step 3)

Why enable Private DNS? When turned on, AWS automatically creates a DNS record inside the VPC that resolves {api-id}.execute-api.{region}.amazonaws.com to the VPCE’s private IP instead of its public IP. This lets ECS call the normal URL while the traffic still stays inside the private network.

If it is off, you have to call the VPCE DNS directly with a special header, which is far more complicated.

Condition for Private DNS to work: the VPC must have both DNS Resolution and DNS Hostnames enabled. Check this at: VPC → Your VPCs → select VPC → Edit VPC settings

Step 2 (Account B): Create a Private REST API Gateway

API Gateway → Create API → REST API
  API endpoint type : Private
  VPC endpoint IDs  : LEAVE EMPTY

Important note: the VPC endpoint IDs field only accepts a VPCE from the same account. Entering Account A’s VPCE here will trigger a “not a valid VPC endpoint ID” error. Cross account authorization is handled entirely through the Resource Policy in the next step.

Create a resource and method, for example GET /mock with a Mock integration, then deploy it to the v0 stage.

Step 3 (Account B): Attach a Resource Policy to the API Gateway

This is the most important step for allowing cross account access. The Resource Policy works like a firewall at the API layer, checking every request before it is processed.

Go to API → Resource Policy → paste in the following policy:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Deny",
      "Principal": "*",
      "Action": "execute-api:Invoke",
      "Resource": "arn:aws:execute-api:ap-northeast-1:*:*/*/*/*",
      "Condition": {
        "StringNotEquals": {
          "aws:sourceVpce": "vpce-xxxxxxxxxxxxxxxxx"
        }
      }
    },
    {
      "Effect": "Allow",
      "Principal": "*",
      "Action": "execute-api:Invoke",
      "Resource": "arn:aws:execute-api:ap-northeast-1:*:*/*/*/*"
    }
  ]
}

Why do you need both Deny and Allow?

  • Statement 1: deny every request that does not come from Account A’s VPCE
  • Statement 2: allow everything else

AWS evaluates Deny before Allow, so the result is: only requests coming from the correct VPCE get through, and every other source is blocked, including requests from the internet or from other accounts.

Replace vpce-xxxxxxxxxxxxxxxxx with the actual VPCE ID from Step 1.

You must redeploy the API after changing the Resource Policy, otherwise the policy will not take effect. Actions → Deploy API → select stage → Deploy

Step 4 (Account A): Configure Security Groups

Use three separate Security Groups. This is the part that is easiest to get wrong:

sg-alb

DirectionTypePortSource / Dest
InboundHTTPS4430.0.0.0/0
OutboundHTTP80sg-ecs

sg-ecs (attached to the ECS task)

DirectionTypePortSource / Dest
InboundHTTP80sg-alb
OutboundHTTPS443sg-vpce

sg-vpce (attached to the VPC Endpoint)

DirectionTypePortSource / Dest
InboundHTTPS443sg-ecs
OutboundAllAll0.0.0.0/0

Why does the VPCE need an Inbound rule instead of Outbound?

Many people get confused here. Traffic flows from the ECS task into the VPCE’s ENI, so from the VPCE’s point of view, that is inbound traffic. The VPCE does not pull data in; it receives the request from ECS and forwards it onward.

Step 5 (Account A): Check the ECS task configuration

The ECS task must use networkMode: awsvpc to get its own Security Group:

{
  "networkMode": "awsvpc",
  "networkConfiguration": {
    "awsvpcConfiguration": {
      "subnets": ["subnet-xxxxxxxx"],
      "securityGroups": ["sg-ecs-id"],
      "assignPublicIp": "DISABLED"
    }
  }
}

If you use networkMode: bridge or host, the ECS task will use the EC2 host’s Security Group, so you cannot control traffic separately for each task.

Warning: the ECS task’s subnet must be in the same AZ as the subnet chosen for the VPCE. If the AZs differ, traffic will still work but you will pay extra cross AZ cost and latency.

Errors encountered, and how to read each one

curl: (6) Could not resolve host

DNS could not resolve the hostname. Check right away:

# 1. Is the VPCE's Private DNS enabled?
aws ec2 describe-vpc-endpoints \
  --vpc-endpoint-ids vpce-xxxxxxxxx \
  --region ap-northeast-1 \
  --query 'VpcEndpoints[0].PrivateDnsEnabled'

# Must return: true 

# 2. Are the VPC's DNS settings enabled?
aws ec2 describe-vpc-attribute --vpc-id vpc-xxxxxx --attribute enableDnsSupport
aws ec2 describe-vpc-attribute --vpc-id vpc-xxxxxx --attribute enableDnsHostnames

# Both must be: true 
# 3. nslookup from inside the container, must return a private IP
nslookup {api-id}.execute-api.ap-northeast-1.amazonaws.com
# Correct result: 10.x.x.x or 172.x.x.x
# Wrong result: a public IP (3.x.x.x, 52.x.x.x, ...)

curl: (28) Failed to connect to port 443, timeout

DNS resolved correctly (returning a private IP) but the TCP connection was blocked. 99% of the time the cause is that the VPCE’s Security Group is missing an Inbound rule for port 443 from ECS.

# Check which SG is attached to the VPCE
aws ec2 describe-vpc-endpoints \
  --vpc-endpoint-ids vpce-xxxxxxxxx \
  --query 'VpcEndpoints[0].Groups' 

# Check that SG's Inbound rules
aws ec2 describe-security-groups \
  --group-ids sg-xxxxxxxxx \
  --query 'SecurityGroups[0].IpPermissions'

403, explicit deny in a resource based policy

The TCP connection succeeded, meaning the request reached the API Gateway. But the Resource Policy is blocking it for one of two reasons:

  • The VPCE ID in the policy is wrong, either copied incorrectly or left as a placeholder
  • The API was not redeployed after editing the policy. I hit this one myself and spent a long time debugging it without finding the cause

Fix: recheck the VPCE ID in the policy, then redeploy the API.

A quick debugging trick when you do not know where the error is

Instead of curling the API’s domain, curl the VPCE DNS directly with the x-apigw-api-id header:

# Step 1, get the VPCE's DNS name
aws ec2 describe-vpc-endpoints \
  --vpc-endpoint-ids vpce-xxxxxxxxx \
  --region ap-northeast-1 \
  --query 'VpcEndpoints[0].DnsEntries[0].DnsName' \
  --output text 
# Step 2, curl directly to the VPCE DNS
curl -v https://{vpce-dns-name}/v0/mock \
  -H "x-apigw-api-id: {api-id}"

This approach completely bypasses Private DNS, so the result will point directly to the layer that has the problem:

ResultLayer at faultHow to fix
(28) timeoutSecurity GroupCheck Inbound 443 on sg-vpce
(6) resolveVPCE’s DNSVPCE not yet available or wrong region
403 explicit denyResource PolicyFix the policy and redeploy the API
200 OK here but fails when using the domainPrivate DNSEnable Private DNS on the VPCE

Checklist before you start debugging

  • Are the VPCE and API Gateway in the same region?
  • Are DNS Resolution and DNS Hostnames enabled on the VPC?
  • Is Private DNS enabled on the VPCE?
  • Does nslookup return a private IP (10.x.x.x) or a public IP?
  • Does sg-vpce have an Inbound rule for 443 from sg-ecs?
  • Are the ECS task and the VPCE in the same AZ?
  • Does the ECS task use networkMode: awsvpc?
  • Does the Resource Policy have the correct sourceVpce condition?
  • Was the API redeployed after editing the Resource Policy?

Closing thoughts

Looking back, no single step was too complex. There were just many steps, and each one could fail in its own way. The hard part was not the technical difficulty, it was knowing what to check first.

I hope this saves someone on the team an afternoon. If you run into another error, ping me and I will add it here.

Meet the author

Be Truong

Be Truong

Software Engineer

A developer who loves working behind the scenes, building solid APIs and reliable systems. Enjoys digging into databases, optimizing performance, and figuring out the best way to make things run smoothly.

Solid circle

Sign me up
for the latest news!

Customize software background

Want to customize a software for your business?

Meet with us! Schedule a meeting with us!