Multi-Account: Cross-Account Calls and the Data Perimeter
Same-account, the resource policy and the caller's identity policy are an OR. Cross-account they become an AND, and silence denies. What that changes in the perimeter.
Parts 101 through 104 chose this layer, settled its protocols, built it, and secured it, all inside a single AWS account. Multi-team estates do not stay there. The team boundary tends to land on an account boundary, and that one move rewrites the authorization rules underneath the layer. Same-account, the API’s resource policy and the caller’s identity policy are an OR: either one can allow the call. Cross-account they become an AND: both must explicitly allow it, and silence denies. Everything else in this part follows from that flip, including the two network topologies you can choose between, the policies that carry the data perimeter, and the one guardrail you cannot write at the organization level. This part is policy work rather than measurement: the POC is not deployed, and every rule below is pinned to AWS documentation.
Cross-account turns an OR into an AND
Two policies are evaluated on every call into a private API: the resource policy attached to the API, and the identity policy on the caller’s principal. How they combine depends on whether the two sides share an account, and the API Gateway authorization flow page spells out both cases (authorization flow).
Same-account is permissive. In AWS’s words, “either the IAM user policies or the resource policy must explicitly allow the caller to proceed”. One yes is enough.
Cross-account is not. “If the caller and API owner are from separate accounts, both the IAM policies and the resource policy explicitly allow the caller to proceed.” The next line is the one to keep: “If either is silent (neither allow nor deny), cross-account access is denied. This is because cross-account access requires that both the resource policy and the IAM policy or Amazon Cognito user pools authorizer explicitly grant access.”
The failure mode is quiet, because nothing in the caller changes. The execution role still grants execute-api:Invoke on the same route ARN. The handler still signs with SigV4. A team simply moved its stack into its own account, and now every call returns 403 with no indication of which of the two policies went silent. This is a failure mode whose cause is not in the code, and it is specific to the account boundary.
Be precise about which policies break, because the part 103 policy is not automatically one of them. Its Allow statement uses Principal: "*", which is an explicit grant and holds across accounts. What breaks first is its Deny: the new account’s endpoint is not in the aws:SourceVpce list, so the policy’s own second statement rejects every call from it. What breaks next is any policy that got specific. A resource policy that enumerates role ARNs is exactly the shape a security review asks for, and the day those ARNs all belong to one account, the policy is silent about every principal outside it. Silence was harmless yesterday. Today it is a denial.
Two topologies, and one of them hides the caller
First, dispose of the worry that naming a foreign endpoint is a hack. It is the documented design: “Use a resource policy to grant your VPCs and VPC endpoints access to your private APIs. You can grant access to a VPC endpoint in any AWS account” (create a private API). The resource policies page lists the same thing among an API’s permitted callers: “Specified virtual private clouds (VPCs) or VPC endpoints (in any account)”. The aws:SourceVpce value in the provider’s policy legitimately holds the consumer account’s vpce- id.
Two ways to arrange the network follow from that.
Topology A, one endpoint per consumer account. Each consumer account runs its own VPC and its own execute-api interface endpoint, and the provider lists each consumer’s endpoint id. This preserves the property the single-account design leaned on: the endpoint id tells the provider which account the call arrived from, independently of who signed it.
It also puts a dent in the cost argument, and the dent belongs here rather than in a footnote. Part 101 rested on the fixed cost being shared: one interface endpoint, about $14.60 per month across two AZs, serving every private API in the VPC. That sharing is per VPC. Twelve accounts with twelve VPCs pay twelve floors, roughly $175 per month before a single request. The per-request rate is unchanged and the per-GB processing charge does not multiply, because traffic is split across the endpoints rather than duplicated. The hourly floor does multiply, linearly, per account. An estate wide enough to justify separate accounts is exactly the estate that feels it.
Topology B, a shared network VPC. A network account owns the VPC and the endpoint and shares subnets with the consumer accounts, whose Lambdas run in them. The floor collapses back to a single endpoint. Two documented facts define what that buys and what it costs. Access still works: “You can connect to all AWS services that support PrivateLink using a VPC endpoint in a shared VPC” (VPC sharing). Ownership does not transfer: “You can’t create, describe, modify, or delete VPC endpoints in subnets that are shared with you” (create an interface endpoint).
Read the consequence carefully, because it is the sharp edge of this part. In topology B, every team’s call arrives through the network account’s endpoint. aws:SourceVpce resolves to the same id for all of them. The key stops identifying the calling team and starts identifying the network. Anything you built on the assumption that the endpoint id says who is calling now says nothing.
Which is why the SigV4 principal carries the weight. Team identity has to come from aws:PrincipalArn, aws:PrincipalAccount, or aws:PrincipalOrgID, evaluated on an AWS_IAM method. That is the workload identity part 104 separated from the end user’s identity, and under a shared VPC it is the only identity the perimeter has. A network-only perimeter was never sufficient here. In topology B it is not even a signal.
| Topology A: endpoint per consumer account | Topology B: shared network VPC | |
|---|---|---|
| Endpoint hourly floor | one per consumer account | one, in the network account |
aws:SourceVpce identifies | the calling account | the network account, for every team |
| Endpoint policy owner | the consumer team | the network account |
Provider’s vpce enumeration | grows with every new account | one id, stable |
| Team identity comes from | SigV4 principal, with the network as a second signal | SigV4 principal only |
Take topology A by default. It keeps the network signal, it lets each consumer team own its own endpoint policy, and it keeps the blast radius describable in two independent dimensions. Take topology B when the endpoint floor across a dozen accounts starts to dominate the layer’s bill, or when a central network team already owns shared subnets and that argument is settled. You are trading the network half of the perimeter for a smaller fixed cost, and you are moving the consumer’s own gate into somebody else’s account. Sign it knowingly.
Writing the perimeter into the resource policy
The governing frame is AWS’s own: “A data perimeter is set of permission guardrails in your AWS environment which help ensure that only your trusted identities are accessing trusted resources from expected networks” (data perimeters). Three perimeters, three places in this layer. Identity: aws:PrincipalOrgID in the API’s resource policy. Resource: an SCP conditioned on aws:ResourceOrgID, so a team’s Lambda cannot call an outsider’s API. Network: aws:SourceVpce in the resource policy, plus the VPC endpoint policy.
The condition-key table for API Gateway resource policies splits by authorization type, and the split matters. Under AWS_IAM authorization you get aws:PrincipalOrgID, aws:PrincipalOrgPaths, aws:PrincipalAccount, and aws:PrincipalArn. Under any authorization type, on private APIs only, you get aws:SourceVpc, aws:SourceVpce, and aws:VpcSourceIp (condition keys). The identity keys exist for you only because part 103 put AWS_IAM in defaultMethodOptions. On a NONE method there is no principal to condition on. Note also what aws:PrincipalOrgID depends on: the key “is included in the request context only if the principal is a member of an organization”.
What is absent from that table matters more. aws:SourceOrgID, aws:ResourceOrgID, aws:VpceOrgID, aws:VpceAccount, and aws:VpceOrgPaths are all missing. An API Gateway resource policy therefore cannot say any VPC endpoint owned by any account in my organization. It can only enumerate concrete endpoint ids. AWS is cautious about those keys in general. On its data perimeters page (data perimeters) it warns that “we recommend that you use aws:VpceOrgID, aws:VpceOrgPaths, and aws:VpceAccount only if all services you want to restrict access to are currently supported. Using these condition keys with unsupported services can lead to unintended authorization results”.
So the policy splits the work: identity comes from the org key, network comes from a list.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowOrgPrincipalsOnOneRoute",
"Effect": "Allow",
"Principal": "*",
"Action": "execute-api:Invoke",
"Resource": ["execute-api:/v1/POST/orders"],
"Condition": {
"StringEquals": { "aws:PrincipalOrgID": "o-abcd1234" }
}
},
{
"Sid": "DenyUnexpectedNetworks",
"Effect": "Deny",
"Principal": "*",
"Action": "execute-api:Invoke",
"Resource": ["execute-api:/*"],
"Condition": {
"StringNotEquals": {
"aws:SourceVpce": [
"vpce-0a1b2c3d4e5f60000",
"vpce-0a1b2c3d4e5f60001",
"vpce-0a1b2c3d4e5f60002"
]
}
}
}
]
}
Read it as two independent gates rather than one rule. The Allow admits any principal in the organization, on one route: that is the identity perimeter, and it is what makes the cross-account AND resolve to a yes on the resource-policy side. The Deny rejects anything that did not arrive through a known endpoint: that is the network perimeter. Neither statement can do the other’s job, and the reason the second one is a hand-maintained list rather than a condition on the organization is exactly the gap above.
That list is the maintenance cost of topology A. Every new consumer account adds an id, and adding an id is a deploy in the provider’s account. Twelve accounts is twelve entries plus a change-management step whenever the thirteenth arrives. Topology B collapses the list to one stable id, which is a fair part of its appeal, as long as you remember what that single id no longer tells you.
The endpoint policy is the consumer’s own gate
AWS divides the two policies cleanly: “VPC endpoint policies can be used together with API Gateway resource policies. The API Gateway resource policy specifies which principals can access the API. The endpoint policy specifies who can access the VPC and which APIs can be called from the VPC endpoint. Your private API needs a resource policy but you don’t need to create a custom VPC endpoint policy” (endpoint policies).
Order of evaluation is fixed, and so is what the endpoint can see: “The identity of the invoker is evaluated based on the Authorization header value. The VPC endpoint policy is evaluated first, and then API Gateway evaluates the request, based on the type of authorization configured on the method request.” With a valid SigV4 signature the invoker is the authenticated IAM identity. With no Authorization header, or with a bearer token in it, the invoker is anonymous. A Principal-based endpoint policy therefore does useful work only when the method is AWS_IAM or NONE. Put a Lambda authorizer with a JWT on the route and the endpoint policy’s principal conditions match nothing.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "OrgOnlyApisFromOrgPrincipals",
"Effect": "Allow",
"Principal": "*",
"Action": "execute-api:Invoke",
"Resource": "*",
"Condition": {
"StringEquals": {
"aws:ResourceOrgID": "o-abcd1234",
"aws:PrincipalOrgID": "o-abcd1234"
}
}
}
]
}
Note the asymmetry: aws:ResourceOrgID works here, and it is not in the API Gateway resource-policy condition-key table at all. The endpoint policy is evaluated by PrivateLink rather than by API Gateway, so it gets a different condition-key surface. That asymmetry is a large part of why this gate is worth writing.
It is also the only gate the consumer’s own account controls. A resource policy guards one API and is written by the team that owns it. The endpoint policy is written by the team that owns the endpoint, and it can say something no resource policy anywhere can express: my functions may call these APIs and no others, and only inside my organization. That is a statement about the caller’s blast radius, not the callee’s. Narrow Resource from * to a list of API ARNs and it becomes an allowlist of every internal dependency the account has, in one file.
Three constraints shape it (endpoint policy rules). The policy must contain a Principal element. Its size cannot exceed 20,480 characters, including white space. And wildcards do not work with condition keys that reference system-generated identifiers, aws:SourceVpc among them. That last rule is one more reason a network perimeter is enumerated rather than matched by prefix. The character cap is what eventually pushes an enumerated ARN list back into a condition-based policy.
One consequence of topology B lands here, and it is easier to miss than the cost one. If the endpoint lives in a shared network VPC, the consumer cannot write this policy at all: “You can’t create, describe, modify, or delete VPC endpoints in subnets that are shared with you”. The consumer’s own gate becomes a ticket to the network team.
What you cannot enforce centrally
The reflex is to push a guardrail into AWS Organizations. For this layer, half of that reflex is wrong.
Resource control policies do not cover execute-api. It is not on the list of services RCPs support (RCPs). AWS’s instruction for that case is direct, and it is on the data perimeters page rather than the RCP page (data perimeters): “To enforce data perimeter controls on resources that are currently not supported by RCPs, you can use resource-based policies that are attached to resources directly.”
There is therefore no organization-level statement that says no principal outside my organization may invoke my APIs. The per-API resource policy is not a defense-in-depth extra. It is the only place the identity and network perimeter for these APIs can be written, which raises its status: a missing resource policy is not a hardening gap, it is the whole perimeter missing. A platform team’s job is to enforce that every private API has one. That enforcement lives in the pipeline rather than in the org. A CDK aspect can fail synth when a RestApi carries no policy, and a Config rule can flag one in an account the platform team does not deploy into.
The identity half does work centrally. An SCP can stop a team’s principals from calling an API outside the organization, which is the resource perimeter of the data-perimeter model:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyApiCallsOutsideOrg",
"Effect": "Deny",
"Action": "execute-api:Invoke",
"Resource": "*",
"Condition": {
"StringNotEquals": { "aws:ResourceOrgID": "o-abcd1234" }
}
}
]
}
You can extend that with a network condition on aws:SourceVpce, using StringNotEqualsIfExists so that requests where the key is absent are not caught by accident. The key is only populated when the request traverses a VPC endpoint, which is why the IfExists form is not optional there.
One more gap, stated plainly, because a false reassurance is worse than a known hole. IAM Access Analyzer does not analyze API Gateway resource policies. execute-api appears in neither the external-access nor the internal-access supported resource list (Access Analyzer resources). Picture an over-broad private API policy: a wildcard principal, no organization condition, and a deny list that quietly stopped matching. The tool your organization bought for exactly that job will not flag it. Catch it in code review, or write the check yourself.
Give the API a name that survives
An API id is not an address. Recreate the API and abc123.execute-api.us-east-1.amazonaws.com becomes something else. Every caller’s environment variable, every route ARN in every consumer’s IAM policy, and every endpoint policy that named it are now wrong. Cross-account, those callers live in repositories you cannot grep.
Private custom domain names close that. “You can create a custom domain name for your private APIs. Use a private custom domain name to provide API callers with a simpler and more intuitive URL” (private custom domain names). And, usefully for a multi-account estate: “Custom domain names for private APIs don’t need to be unique across multiple accounts.” So orders.internal.example.com becomes the contract and the API id becomes an implementation detail behind it.
The wiring has more pieces than a public custom domain, and all of them are worth knowing before you start. The provider needs an ACM certificate, RSA-2048 or ECDSA P-256 or P-384; wildcard certificates are allowed, wildcard domain names are not. TLS is not a dial: “You can’t set a minimum TLS version for your private custom domain name. All private custom domain names have the security policy of TLS-1-2.” The consumer needs a domain name access association between its VPC endpoint and the domain, and that association “takes about 15 minutes to be ready”. It also needs a Route 53 private hosted zone so the name resolves. And the boundary is hard: “You can’t send traffic from a private custom domain name to a public API.”
The authorization chain grows by one, and this is the sentence to paste into the design doc: “You must create a separate resource policy for your private API and for your private custom domain name. To invoke a private custom domain name, an API consumer needs access from the private custom domain name resource policy, the private API resource policy, and any VPC endpoint policies or authorization on the private API.” Four things must agree, not three, and the new one is easy to forget because it guards a name rather than a service.
Sharing is a first-class flow rather than a workaround. “We recommend that you use AWS RAM to share your private custom domain name”, and inside an organization the handshake disappears: “If the API provider and the API consumer are in the same organization using AWS Organizations, the resource share between provider and consumer is automatically accepted” (domain name access associations).
Name the honest trade. The domain name is stable, but the domain’s ARN carries a generated domain-name-id suffix. Recreate the domain and that ARN changes, which moves the churn from every caller’s configuration to the RAM share and the endpoint policies that referenced it. Recreation churn is relocated, not abolished. It lands on fewer things, owned by fewer teams, and that concentration is the improvement you are buying.
Two facts about addressing belong here because they interact with the shared endpoint. AWS’s best practice is to “Use a single VPC endpoint to access multiple private APIs” and to “Turn on private DNS for your VPC. When you turn on private DNS for your VPC, you can invoke your API within a VPC without passing the Host or x-apigw-api-id header” (private APIs). Many APIs behind one endpoint do not collide, because the API id sits in the hostname and the endpoint disambiguates them without a header. That is the good news, and it is what makes the shared endpoint practical in the first place.
The trap is on the same page: “If you turn on private DNS, you can’t access the default endpoint for public APIs.” A VPC with private DNS on for execute-api can no longer reach public API Gateway endpoints on their default hostnames. If the same functions call a third-party API that happens to be hosted on API Gateway, that call stops working. The documented workaround is to turn private DNS off and create a private hosted zone per private API, which is more moving parts in the consumer account. Check this before you flip the switch, not after.
Common pitfalls
- Assuming the single-account resource policy carries over unchanged when a team moves into its own account. Cross-account needs an explicit allow on both sides, the new endpoint id has to join the network list, and any policy that enumerated same-account role ARNs is now silent about the caller.
- Using
aws:SourceVpceas a team identifier under a shared network VPC. Every team arrives through the same endpoint id, so the key identifies the network and nothing else. Team identity comes from the SigV4 principal. - Expecting an organization-scoped network key. There is no
aws:VpceOrgIDin the API Gateway resource-policy condition table, so the endpoint list is enumerated and maintained by hand. - Waiting for an RCP to guard
execute-api. It is not a supported service, so the per-API resource policy is the only place the perimeter exists. - Telling the team that IAM Access Analyzer will flag an over-broad API resource policy. API Gateway is not a supported resource type in either analyzer.
- Putting a bearer-token authorizer behind a
Principal-based endpoint policy. Without SigV4 the invoker is anonymous and the principal conditions never match. - Turning on private DNS in a VPC whose functions also call public API Gateway APIs. The default public endpoint stops resolving from that VPC.
- Treating the API id as a stable address across accounts. Use a private custom domain name, and remember that recreating the domain still churns the RAM share and the endpoint policies.
Cross-account is not a variant of the single-account design. It is a different authorization rule wearing the same syntax. The default holds under three conditions. The provider’s resource policy names the organization on the identity side and enumerates endpoint ids on the network side. Each consumer account owns its own endpoint and its own endpoint policy. And callers address the API by a private custom domain name rather than by a generated id. Reach for the shared network VPC when the per-account endpoint floor outweighs what the endpoint id was telling you, and accept that identity is then entirely SigV4’s job. The first artifact to produce is the list: which accounts call which APIs today. That list is the enumeration your resource policies are about to need, and its length is the argument for or against topology B.
The layer now spans accounts, and the perimeter is written down. The design has not yet met real traffic. Part 106 is about what breaks once it carries it: the throttle bucket it now shares with your front door, the retries it removed, and the loop detector it gave away.
References
- API Gateway authorization flow - The spine of this part: same-account is an OR, cross-account requires both policies to explicitly allow, and “if either is silent… cross-account access is denied”.
- Create a private API - “You can grant access to a VPC endpoint in any AWS account.” Naming a foreign endpoint in a resource policy is the intended design.
- API Gateway resource policies - Lists “Specified virtual private clouds (VPCs) or VPC endpoints (in any account)” among an API’s permitted callers.
- Condition keys for API Gateway resource policies - The two supported lists, and the absence of
aws:VpceOrgIDand friends that forces you to enumerate endpoint ids. - VPC endpoint policies for private APIs - The division of labour between the two policies, the evaluation order, and the anonymous-invoker rule that makes a
Principal-based endpoint policy useless behind a bearer token. - Control access to VPC endpoints using endpoint policies - The three constraints on an endpoint policy: a required
Principalelement, a 20,480-character cap including white space, and no wildcards with condition keys that reference system-generated identifiers. - Private REST APIs in API Gateway - One endpoint for many private APIs, private DNS removing the
Hostheader requirement, and the public-endpoint trap that comes with it. - Private custom domain names - Certificate types, the fixed TLS-1-2 policy, and the three-policy chain a consumer must satisfy to invoke through a domain name.
- Domain name access associations - The AWS RAM sharing flow, the automatic acceptance inside an organization, and the roughly 15 minutes an association takes to become ready.
- Create an interface VPC endpoint - “You can’t create, describe, modify, or delete VPC endpoints in subnets that are shared with you.” The line that moves the endpoint policy to the network account.
- VPC sharing service behavior - Confirms PrivateLink works from a shared VPC, which is what makes topology B viable in the first place.
- Data perimeters on AWS - The identity, resource, and network perimeter model, plus the instruction to fall back to resource-based policies where RCPs do not reach.
- Resource control policies - The supported service list that
execute-apiis missing from, which is why no org guardrail can cover these APIs. - IAM Access Analyzer supported resource types - API Gateway is absent from both the external-access and internal-access lists. Do not expect it to catch an over-broad API resource policy.
Serverless Internal Service Layer: A Mesh You Compose, Not Deploy
Building service-mesh-like east-west communication for a multi-team serverless estate on a private API Gateway. Identity, encryption, wire formats, cost, and where the industry is actually heading.
All Posts in This Series
Related posts
Before building an internal service layer, decide whether to. What the layer costs per call, the volume where VPC Lattice wins, and when direct invoke still beats it.
A private REST API structurally cannot carry gRPC, and every AWS surface that speaks gRPC excludes Lambda targets. What to keep from gRPC, and what to drop.
The private REST API, the resource policy that switches it on, per-route AWS_IAM grants, the two CDK stacks, and signing the call from a Node 22 Lambda.
SigV4 proves which service is calling and nothing about which user it is for. How to propagate a verified subject, and what the transport actually encrypts.
API Gateway shares one throttle bucket with your front door, never retries a Lambda integration, and cannot see loops through itself. What you have to rebuild.