Private endpoints & east-west TLS
PrivateLink, endpoint policies, mTLS internally.
A backend service sitting in a private subnet calls S3, Amazon's file storage, to read one file. Nothing about that sounds dangerous. Yet by default that call leaves your network, crosses the public internet, and comes back. That round trip is where a surprising amount of AWS data quietly gets exposed. The fix is simple to say: stop walking outside. Bring the service into your own building and keep every hop indoors.
Bring the service inside the VPC
A VPC, short for Virtual Private Cloud, is your own fenced-off network inside AWS, a private slice of their data centre that only you control. S3, DynamoDB and Secrets Manager (Amazon's file store, database and password vault) normally sit outside that fence, each with a public front door. Reaching them means routing out to the internet and back, even though both ends live in the same AWS region. It's two rooms in the same building where the only way across is to walk out the front door, down the public street, and back in through the other entrance. A VPC endpoint builds a private hallway between the rooms instead. The traffic never touches the street.
There are two kinds, and the difference is mechanical. A gateway endpoint is free and works for exactly two services, S3 and DynamoDB. It's really just an entry in your route table, the list of directions that tells traffic where to go, saying: anything bound for S3, send it down this private path. An interface endpoint, which AWS also calls PrivateLink, is an actual network card, an elastic network interface (ENI), planted inside your subnet with its own private IP address. It covers almost every other AWS service and plenty of partner APIs. It carries a small hourly fee per availability zone plus a per-gigabyte charge, so you point it at the services you genuinely call rather than blanketing the whole catalogue.
$ aws ec2 create-vpc-endpoint \--vpc-id vpc-0a1b2c3d4e5f6a7b8 \--vpc-endpoint-type Gateway \--service-name com.amazonaws.eu-west-1.s3 \--route-table-ids rtb-0f9e8d7c6b5a4c3d2{"VpcEndpoint": {"VpcEndpointId": "vpce-0c1d2e3f4a5b6c7d8","VpcEndpointType": "Gateway","ServiceName": "com.amazonaws.eu-west-1.s3","State": "available","RouteTableIds": ["rtb-0f9e8d7c6b5a4c3d2"],"CreationTimestamp": "2026-07-16T09:12:44+00:00"}}$ aws ec2 create-vpc-endpoint \--vpc-id vpc-0a1b2c3d4e5f6a7b8 \--vpc-endpoint-type Interface \--service-name com.amazonaws.eu-west-1.secretsmanager \--subnet-ids subnet-0aa1b2c3 subnet-0dd4e5f6 \--security-group-ids sg-0e1d2c3b4a596877 \--private-dns-enabled{"VpcEndpoint": {"VpcEndpointId": "vpce-09f8e7d6c5b4a3f21","VpcEndpointType": "Interface","ServiceName": "com.amazonaws.eu-west-1.secretsmanager","State": "pending","PrivateDnsEnabled": true,"NetworkInterfaceIds": ["eni-0aa11bb22cc33dd44", "eni-0ee55ff66aa77bb88"],"DnsEntries": [{"DnsName": "secretsmanager.eu-west-1.amazonaws.com", "HostedZoneId": "Z2GHUB4EXAMPLE"}]}}
Both services now answer from inside the fence, with no internet gateway or NAT device (the box that lets private machines dial out to the internet) anywhere in the path. Turning on private DNS for the interface endpoint quietly rewrites the service's public name to that internal IP. DNS is just the address book of the internet, the thing that turns a name like secretsmanager.eu-west-1.amazonaws.com into an actual address, and here you're swapping the public address for a private one. Your application code never changes. It keeps calling the same hostname as before, and the packets stay indoors. One endpoint serves every subnet in the VPC, so you pay for reach, not per caller.
Put a lock on the hallway door
A private path is not automatically a controlled path. Out of the box an endpoint ships with a wide-open policy, so anything your permissions already allow can flow straight through it. That's what an endpoint policy is for: a second, independent gate. IAM, which stands for Identity and Access Management and is the AWS system that decides who is allowed to do what, is the bouncer at the front door checking who you are. The endpoint policy is a second bouncer standing in the hallway, checking which room you're allowed into, whoever you turn out to be. If a compromised role has broad S3 access, an endpoint policy that names only acme-data means the endpoint itself refuses to carry a request for any other bucket. That's a hard ceiling on data theft, enforced by the network path rather than by identity.
{"Version": "2012-10-17","Statement": [{"Sid": "OnlyAcmeDataFromOurOrg","Effect": "Allow","Principal": "*","Action": ["s3:GetObject", "s3:PutObject", "s3:ListBucket"],"Resource": ["arn:aws:s3:::acme-data","arn:aws:s3:::acme-data/*"],"Condition": {"StringEquals": { "aws:PrincipalOrgID": "o-ab12cd34ef" }}}]}
Attach it, then prove it. Never trust a control you haven't watched turn something away.
$ aws ec2 modify-vpc-endpoint \--vpc-endpoint-id vpce-0c1d2e3f4a5b6c7d8 \--policy-document file://s3-endpoint-policy.json{"Return": true}$ aws s3 ls s3://acme-data/PRE incoming/2026-07-15 22:04:11 81920 quarterly.parquet$ aws s3 ls s3://marketing-open-bucket/An error occurred (AccessDenied) when calling the ListObjectsV2 operation: User:arn:aws:sts::111122223333:assumed-role/app-role/i-0ab is not authorized to perform:s3:ListBucket on resource: "arn:aws:s3:::marketing-open-bucket" because no VPCendpoint policy allows the s3:ListBucket action
The rejected bucket is the whole point. That request carried valid IAM permissions and still lost, because the destination wasn't on the endpoint's list. A single over-broad IAM policy stops being a one-shot path to every bucket in the account. In a setup with many accounts, the aws:PrincipalOrgID condition earns its place: the endpoint carries a request only if the caller belongs to your organization, so a credential leaked outside your accounts gets nothing even if it somehow reaches the path. Do the same for DynamoDB through its gateway endpoint, and for any sensitive interface endpoint where you want only certain APIs reachable.
Encrypt the hallway, not just the front door
TLS, short for Transport Layer Security, is the lock that scrambles data while it's moving so nobody on the wire can read it. Most teams put that lock on only one door. They terminate TLS at the load balancer and let traffic run in plaintext from there to the real service. Stop the encryption at the front door and every step deeper into the building becomes a conversation shouted across an open hallway. One compromised container, or a quiet packet capture on the wire, and all of that internal service-to-service traffic can be read and rewritten.
The answer is mutual TLS, where both sides present a certificate and prove who they are before a single byte flows, the way two people each show ID before they'll talk business. AWS App Mesh used to be the easy button for this, but it's on the way out. AWS ends support for App Mesh on September 30, 2026, so don't anchor anything new to it. The pattern that lasts is a certificate authority you own plus a service mesh (a layer that rides alongside every service and manages how they talk) that enforces mutual TLS for you. Use AWS Private CA, also called ACM PCA, as your root of trust: the in-house passport office that vouches for every workload's identity. Mint short-lived certificates automatically with the aws-privateca-issuer plugin for cert-manager, and let Istio's Envoy sidecars wrap every east-west call (traffic moving service to service inside your network) in mutual TLS, with certificates that rotate on their own.
$ kubectl apply -f - <<'EOF'apiVersion: security.istio.io/v1kind: PeerAuthenticationmetadata:name: defaultnamespace: paymentsspec:mtls:mode: STRICTEOFpeerauthentication.security.istio.io/default created$ istioctl x describe pod checkout-7d9f4-abc12 -n paymentsPod: checkout-7d9f4-abc12Pod Revision: defaultPod Ports: 8080 (checkout), 15090 (istio-proxy)--------------------Effective PeerAuthentication:Workload mTLS mode: STRICTApplied PeerAuthentication:default.payments
Try this
Work through “Encrypt the hallway, not just the front door” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.
Takeaway
The trap worth remembering here: an endpoint policy only guards traffic that uses the endpoint. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.