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

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_IAM` authorization, 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

```plaintext
                 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:

```json
{ "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:

```json
{
  "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:

```python
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:

```json
{
  "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:

```bash
sudo dnf install -y python3-pip
pip3 install --user boto3 requests
```

Then run this script:

```python
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:

```plaintext
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

1.  **Role to temporary credentials.** AWS issues short-lived credentials for the attached role through the instance metadata service. `boto3` picks them up automatically, so nothing is hardcoded.
    
2.  **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.
    
3.  **Gate one, authentication.** AWS recomputes the signature. A match proves the sender holds the role's credentials and the request was not altered.
    
4.  **Gate two, authorization.** IAM checks whether the role's policy allows `execute-api:Invoke` on `prod/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-1` while the Lambda sat in `eu-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 << EOF` block into the browser terminal merged the first two lines, so `cat` treated 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.
