When an AWS REST API Gateway uses a regional public endpoint, it is reachable over the internet by design. That does not mean it has to be open to every caller. For service-to-service traffic, the real goal is usually narrower: keep the API reachable, but only accept requests from a small set of trusted workloads.
In this setup, the calling service signs the request with SigV4, API Gateway verifies that signature, and the API itself applies a resource policy to decide which principals are even allowed to try.
The Setup
The reference flow is a cross-account path. Service A runs in Account A and needs to call Service B in Account B. Instead of exposing Service B directly, Account B publishes a REST API Gateway endpoint and keeps the backend private behind a VPC Link and an internal ALB.
That gives you a useful split of responsibilities. API Gateway becomes the public entry point and policy enforcement layer. The backend stays private, and callers never talk to the internal ALB or ECS service directly.
flowchart LR
A[Service A] -->|SigV4 signed request| B[API Gateway]
B -->|AWS_IAM auth| C[Resource policy check]
C --> D[Private backend via VPC Link and ALB]
What Actually Locks It Down
Three pieces work together here.
First, the caller signs the request with AWS Signature Version 4. In practice, that means Service A uses its IAM role credentials to produce a signed HTTPS request for the execute-api service. API Gateway can then verify who sent the request and whether the signature is valid.
const { defaultProvider } = require("@aws-sdk/credential-provider-node");
const { HttpRequest } = require("@smithy/protocol-http");
const { SignatureV4 } = require("@smithy/signature-v4");
const { Sha256 } = require("@aws-crypto/sha256-js");
const signer = new SignatureV4({
credentials: defaultProvider(),
region: targetApiRegion,
service: "execute-api",
sha256: Sha256,
});
const request = new HttpRequest({
protocol: baseUrl.protocol,
hostname: baseUrl.hostname,
method: "POST",
path: requestPath,
headers: {
"content-type": "application/json",
host: baseUrl.host,
},
body,
});
const signedRequest = await signer.sign(request);
const response = await fetch(`${baseUrl.origin}${requestPath}`, {
method: signedRequest.method,
headers: signedRequest.headers,
body,
});
Second, the API method uses AWS_IAM authorization. That means unsigned requests fail, and API Gateway does not treat a bearer token as valid authorization for this method. The caller must send a valid SigV4-signed request tied to an IAM identity that is allowed to invoke the API.
{
"Version": "2012-10-17",
"Statement": [
{
"Action": [
"execute-api:Invoke"
],
"Effect": "Allow",
"Resource": [
"arn:aws:execute-api:[AWS_REGION]:[AWS_ACCOUNT_B]:[API_ID]/demo/*/*"
]
}
]
}
Third, the REST API has a resource policy. This is the part that limits who the API is willing to trust at the resource level. For example, you can allow only principals from a specific AWS account, or narrow it further to specific roles. Even if a caller can produce a valid AWS identity, the resource policy can still block it.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowServiceATaskRoleInvoke",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::[AWS_ACCOUNT_A]:role/[SERVICE_A_TASK_ROLE]"
},
"Action": "execute-api:Invoke",
"Resource": "arn:aws:execute-api:[AWS_REGION]:[AWS_ACCOUNT_B]:[API_ID]/*/*/*"
}
]
}
The Practical Result
This gives you a cleaner service-to-service boundary than leaving the API open and relying on application logic later. Unsigned requests fail. Requests signed by the wrong role fail. Requests from accounts not covered by the API resource policy fail.
What remains is a narrow path for the intended workload: Service A signs the request with its task role, API Gateway validates the IAM-based access, and only then forwards traffic to the private backend. That is the core idea behind locking down API Gateway for internal service communication without exposing the backend itself.