Zero Trust on AWS: Same Machine, Same URL, Two Different Answers

A software developer and technical writer passionate about technology, communication, and bridging the gap between technical complexity and user-friendly experiences.
I create user-friendly content that simplifies complex concepts for audiences of varying technical backgrounds.
I sent two requests from the same EC2 instance, inside the same VPC, to the same API URL. One got a 403. The other got back a record marked TOP SECRET. The only difference was a cryptographic signature proving who sent it.
That is zero trust in one sentence: access comes from proven identity, never from network location. This post walks through how I built it on AWS with DynamoDB, Lambda, API Gateway, IAM, a VPC and EC2, including the mistakes I made along the way.
TL;DR
API Gateway is set to
AWS_IAMauthorization, so every request must carry a valid SigV4 signature.The EC2 client gets an IAM role that can invoke one route and nothing else.
The Lambda behind it can only read one table.
Result:
UNSIGNED: 403,SIGNED: 200. Same machine, same network, same URL.
Why "inside the network" is not a security model
The traditional setup trusts location. Join the office Wi-Fi or the VPN and internal tools open up without asking who you are again.
The weakness is obvious once you say it out loud: an attacker who gets one laptop or one password inherits that same trust. They don't need to break anything. They just need to be inside.
Zero trust removes that shortcut. Every request proves its identity, every component gets the smallest permission it needs, and the network provides isolation, not access.
The architecture
signed
┌─ VPC (isolation only) ─┐ ┌──────────────┐ ┌──────────────┐ ┌───────────────────┐
│ ┌──────────────────┐ │ ───► │ API Gateway │ ───► │ Lambda │ ───► │ DynamoDB │
│ │ EC2 client │ │ │ Auth: AWS_IAM│ │ Role: read │ │ Encrypted at rest │
│ │ Role: invoke only│ │ └──────────────┘ └──────────────┘ └───────────────────┘
│ └──────────────────┘ │ ▲ Signed caller gets 200
└──────────┬─────────────┘ │
│ unsigned ┌──────┴───────┐
└──────────────────► │ 403 Blocked │
│ Lambda never │
│ runs │
└──────────────┘
The client sits inside a VPC, but that earns it nothing. API Gateway checks the signature and the IAM policy before Lambda ever wakes up, and each component holds only the one permission it needs.
The build, step by step
Everything lives in one region (I used eu-north-1). Replace ACCOUNT_ID and API_ID with your own values.
1. DynamoDB: the thing worth stealing
Create a table named Inventory with partition key id (String). Encryption at rest is on by default. Add one item:
{ "id": "1", "name": "Gadget", "status": "TOP SECRET" }
2. Lambda role: read, never write
Create an IAM role trusted by Lambda. Attach AWSLambdaBasicExecutionRole for logs, plus this inline policy:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["dynamodb:Scan", "dynamodb:GetItem"],
"Resource": "arn:aws:dynamodb:eu-north-1:ACCOUNT_ID:table/Inventory"
}]
}
IAM policies add up, and AWS denies anything not granted. No policy grants PutItem or DeleteItem, so even hijacked code cannot change the data.
3. Lambda: the backend
Python runtime, using the role above:
import json, boto3
table = boto3.resource("dynamodb").Table("Inventory")
def lambda_handler(event, context):
items = table.scan()["Items"]
return {
"statusCode": 200,
"body": json.dumps({"message": "Zero trust verified", "count": len(items), "items": items})
}
Leave Function URL off. It would create a second door that skips the gateway's identity check.
4. API Gateway: the locked door
Create a REST API with resource /inventory and a GET method using Lambda proxy integration. Then set Method request → Authorization → AWS IAM. Deploy to a prod stage.
Opening the URL in a browser now returns Missing Authentication Token with a 403. Lambda never even runs.
5. VPC: isolation, not trust
Use "VPC and more" with one public subnet, an internet gateway, and no NAT gateway. Keep DNS resolution on so the instance can resolve the API hostname.
6. EC2 client: an identity, not a key
Create an IAM role trusted by EC2 with only this permission:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": "execute-api:Invoke",
"Resource": "arn:aws:execute-api:eu-north-1:ACCOUNT_ID:API_ID/prod/GET/inventory"
}]
}
Launch an Amazon Linux instance in the public subnet with that role attached. Choose no key pair. Restrict SSH to the EC2 Instance Connect prefix list and connect through Instance Connect, which pushes a short-lived key per session.
The proof: one script, two answers
On the instance, install the tools:
sudo dnf install -y python3-pip
pip3 install --user boto3 requests
Then run this script:
import boto3, requests
from botocore.auth import SigV4Auth
from botocore.awsrequest import AWSRequest
URL = "https://API_ID.execute-api.eu-north-1.amazonaws.com/prod/inventory"
REGION = "eu-north-1"
# No identity
r = requests.get(URL)
print("UNSIGNED:", r.status_code, r.text)
# Identity from the instance role
creds = boto3.Session().get_credentials()
req = AWSRequest(method="GET", url=URL)
SigV4Auth(creds, "execute-api", REGION).add_auth(req)
r = requests.get(URL, headers=dict(req.headers))
print("SIGNED:", r.status_code, r.text)
My output:
UNSIGNED: 403 {"message":"Missing Authentication Token"}
SIGNED: 200 {"message": "Zero trust verified", "count": 1, "items": [{"id": "1", "name": "Gadget", "status": "TOP SECRET"}]}
What actually happened in the signed request
Role to temporary credentials. AWS issues short-lived credentials for the attached role through the instance metadata service.
boto3picks them up automatically, so nothing is hardcoded.Credentials to signature. SigV4 uses the secret key to compute an HMAC over the method, URL, headers and timestamp. The request carries the access key ID, the session token and the signature. The secret key itself never leaves the instance.
Gate one, authentication. AWS recomputes the signature. A match proves the sender holds the role's credentials and the request was not altered.
Gate two, authorization. IAM checks whether the role's policy allows
execute-api:Invokeonprod/GET/inventory. It does, so Lambda runs.
The unsigned request fails at gate one. A signed request from a role without that policy would fail at gate two.
The principles, mapped to the build
| Principle | Where it shows up |
|---|---|
| Verify every request | API Gateway AWS_IAM auth plus SigV4 |
| Least privilege | Lambda reads one table; EC2 invokes one route |
| Network is not trust | The VPC isolates traffic but grants nothing |
| Protect the data itself | DynamoDB and EBS encrypted at rest |
| No long-lived secrets | No SSH key pair, no hardcoded credentials, only temporary role credentials |
Gotchas I hit
Wrong region. My first API Gateway landed in
us-east-1while the Lambda sat ineu-north-1. Cross-region integration is possible with a full ARN, but it adds latency, cost and confusion. I deleted it and rebuilt in one region.Heredoc paste broke. Pasting a
cat > file << EOFblock into the browser terminal merged the first two lines, socattreated Python keywords as filenames. Typing the first line on its own, then pasting the body, fixed it.Policies don't restrict each other. I first assumed the inline policy "limited" the basic execution policy. It doesn't. Policies add up; the role is read-only only because nothing grants writes.
Cost and cleanup
DynamoDB on-demand, Lambda, API Gateway and IAM cost effectively nothing at lab scale. The things that bill by the hour are the EC2 instance and its public IPv4 address, so terminate the instance as soon as the test passes. Then delete the VPC, API, Lambda, its log group, the table and both roles. A zero-spend budget alert is a cheap safety net.
The takeaway
Zero trust is less a product than a habit: give every component its own identity, give that identity the smallest permission it needs, and make every call prove itself. Watching a 403 turn into a 200 purely because of a signature is the moment the idea stops being abstract.



