Skip to main content

Command Palette

Search for a command to run...

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

Updated
•7 min read•View as Markdown
Zero Trust on AWS: Same Machine, Same URL, Two Different Answers
N

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_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

                 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

  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.