ZYVOPMulti-Platform Sync
SeriesAI NewsWhy ZyVOPJoin Discord
LoginGet Started
ZYVOP
The Developer Publishing Hub
PrivacyTermsGuidelinesDMCACommunity
© 2026 ZyVOP
HomeBuilding a Zero-Trust Internal API on AWS

Building a Zero-Trust Internal API on AWS

A working AWS pattern where every request has to prove its identity, and network location earns nothing on its own.

Tomson Alex
Tomson Alex
Blogger
July 26, 2026
6 min read
Building a Zero-Trust Internal API on AWS
#AWS#DynamoDB#Python#Zero Trust#Terraform#API Gateway#IAM#EC2#nodejs#Lambda

Most internal APIs check one thing before letting a request through: whether it's coming from inside the network.

VPN connected, on the office Wi-Fi, sitting inside the VPC. Any of those, and the internal wiki loads, the admin panel opens, the service-to-service calls go through without a second look. Network location becomes a stand-in for identity, and that substitution is where most lateral-movement breaches start.

The 2022 Uber breach is the textbook case. An attacker bought a contractor's stolen password, then buried the contractor's phone in MFA push notifications until one got approved. That single approval handed over VPN access, and from inside the network the attacker found hardcoded credentials in a PowerShell script and moved into Slack, internal tools, and cloud infrastructure.

Nobody broke encryption or found a zero-day. Somebody just got trusted, once, and the network did the rest.

Google reached the same conclusion internally back in 2014, when it started publishing the BeyondCorp papers describing how it moved every employee off VPN-based trust and onto per-request identity checks. NIST formalized the same idea for the rest of the industry in August 2020 with SP 800-207, which boils down to one sentence: stop granting trust based on network location, and start granting it based on verified identity, for every single request.

That's a clean sentence to say and a fuzzy one to build. So here's a working version on AWS, small enough to read in one sitting and opinionated enough to actually enforce something.

The three rules this build follows

Before touching any AWS console, it helps to pin down what "zero trust" cashes out to for a backend engineer, because the phrase gets used loosely:

  1. Identity over location. A request from inside the VPC gets zero extra credit. It has to prove who it is the same way a request from the public internet would.

  2. Least privilege, scoped to one resource. Every IAM role gets the narrowest permission that lets it do its one job, not "read DynamoDB," but "read this one table."

  3. No standing secrets to steal. No SSH keys sitting in ~/.ssh, no long-lived API keys in a config file. Credentials get issued short-lived and scoped, or not at all.

Everything below is just those three rules turned into Terraform and IAM policy.

What we're protecting

Take a SaaS company's internal entitlement service, the system that tells other services which unreleased features an enterprise account has been unlocked for early access. Leak that table and you leak next quarter's roadmap to anyone paying attention to a competitor's account.

sequenceDiagram
    participant Client as EC2 client<br/>(instance role only)
    participant GW as API Gateway<br/>(AWS_IAM auth)
    participant Fn as Lambda<br/>(read-only role)
    participant DB as DynamoDB<br/>(encrypted at rest)

    Client->>Client: Sign request with SigV4<br/>using temporary instance credentials
    Client->>GW: GET /entitlements/acct_4821 (signed)
    GW->>GW: Verify signature + execute-api:Invoke permission
    GW->>Fn: Invoke (only if signature valid)
    Fn->>DB: GetItem (read-only role)
    DB-->>Fn: item
    Fn-->>GW: 200 + entitlement data
    GW-->>Client: 200 + entitlement data

    Note over Client,GW: Same client, same network,<br/>no signature → 403 at the gateway.<br/>Lambda never runs.

Storing the data

DynamoDB encrypts every table at rest by default, using an AWS-owned key, and you can't turn that off. That's been true for every table since 2018, and AWS provides it whether or not the rest of this design bothers with zero trust.

resource "aws_dynamodb_table" "entitlements" {
  name         = "account-entitlements"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "account_id"

  attribute {
    name = "account_id"
    type = "S"
  }

  point_in_time_recovery {
    enabled = true
  }
  # server_side_encryption block omitted on purpose.
  # The AWS owned key applies automatically and can't be disabled.
}

A Lambda that can only read

This is where least privilege stops being a slogan. The Lambda's execution role gets one permission, dynamodb:GetItem, scoped to one table ARN. It can't PutItem, can't DeleteItem, can't touch any other table in the account.

import json
import boto3

TABLE_NAME = "account-entitlements"
dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table(TABLE_NAME)


def handler(event, context):
    account_id = event.get("pathParameters", {}).get("accountId")
    if not account_id:
        return _response(400, {"error": "accountId is required"})

    result = table.get_item(Key={"account_id": account_id})
    item = result.get("Item")

    if not item:
        return _response(404, {"error": "no entitlement record for this account"})

    return _response(200, item)


def _response(status_code, body):
    return {
        "statusCode": status_code,
        "headers": {"Content-Type": "application/json"},
        "body": json.dumps(body),
    }

The role that ships with it:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadEntitlementsOnly",
      "Effect": "Allow",
      "Action": ["dynamodb:GetItem"],
      "Resource": "arn:aws:dynamodb:us-east-1:111122223333:table/account-entitlements"
    }
  ]
}

If this function ever gets hijacked through a dependency vulnerability, the attacker inherits a role that can look at one table and change nothing else in the account.

Locking the front door with IAM auth

Flip the method's authorizationType from NONE to AWS_IAM, and every request now has to arrive with an AWS Signature Version 4 signature or it never reaches the Lambda:

resource "aws_api_gateway_method" "get_entitlement" {
  rest_api_id   = aws_api_gateway_rest_api.entitlements.id
  resource_id   = aws_api_gateway_resource.account_id.id
  http_method   = "GET"
  authorization = "AWS_IAM"
}

(The aws_api_gateway_integration, aws_api_gateway_deployment, and aws_api_gateway_stage resources around this are standard AWS_PROXY wiring, identical whether or not you turn on IAM auth, so they're skipped here for length. AWS's permissions docs cover that plumbing in full.)

Unauthenticated traffic hits a 403 at the gateway itself. The Lambda doesn't run, doesn't spend a millisecond of compute on a request with no proof of identity behind it.

An EC2 client with an identity instead of a key

The client machine gets a VPC for isolation, not for trust. Its IAM role is scoped to invoke one method on one API:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "InvokeEntitlementsApiOnly",
      "Effect": "Allow",
      "Action": ["execute-api:Invoke"],
      "Resource": "arn:aws:execute-api:us-east-1:111122223333:a1b2c3d4e5/prod/GET/entitlements/*"
    }
  ]
}

No SSH key pair gets attached at launch. For the rare case someone needs a shell on the box, EC2 Instance Connect pushes a one-time public key into the instance metadata that's valid for 60 seconds, authorized entirely through IAM. Nothing to leak, nothing sitting on disk to steal months later.

Same machine, same network, two different outcomes

This is the part worth actually running. First, a signed request using the instance's temporary credentials:

import boto3
import requests
from botocore.auth import SigV4Auth
from botocore.awsrequest import AWSRequest

REGION = "us-east-1"
API_URL = "https://a1b2c3d4e5.execute-api.us-east-1.amazonaws.com/prod/entitlements/acct_4821"


def signed_get(url):
    session = boto3.Session()
    creds = session.get_credentials().get_frozen_credentials()

    request = AWSRequest(method="GET", url=url)
    SigV4Auth(creds, "execute-api", REGION).add_auth(request)

    return requests.get(url, headers=dict(request.headers))


if __name__ == "__main__":
    signed = signed_get(API_URL)
    print("Signed request:", signed.status_code, signed.text)

    unsigned = requests.get(API_URL)
    print("Unsigned request:", unsigned.status_code, unsigned.text)

Run it, and the signed call comes back 200 with the entitlement record. The plain requests.get() right below it, from the same process, same instance, same VPC, comes back 403 before the Lambda ever wakes up. boto3 pulls the role's temporary credentials automatically, nothing hardcoded, nothing to rotate by hand.

If your team runs on Node.js instead, the same signing pattern uses @aws-sdk/signature-v4:

import { SignatureV4 } from "@aws-sdk/signature-v4";
import { HttpRequest } from "@aws-sdk/protocol-http";
import { Sha256 } from "@aws-crypto/sha256-js";
import { defaultProvider } from "@aws-sdk/credential-provider-node";

const REGION = "us-east-1";
const HOST = "a1b2c3d4e5.execute-api.us-east-1.amazonaws.com";
const PATH = "/prod/entitlements/acct_4821";

const signer = new SignatureV4({
  credentials: defaultProvider(),
  region: REGION,
  service: "execute-api",
  sha256: Sha256,
});

async function signedGet() {
  const request = new HttpRequest({
    method: "GET",
    hostname: HOST,
    path: PATH,
    headers: { host: HOST },
  });

  const signed = await signer.sign(request);

  const response = await fetch(`https://${HOST}${PATH}`, {
    method: "GET",
    headers: signed.headers,
  });

  console.log("Signed request:", response.status, await response.text());
}

signedGet();

Same idea, different runtime. The SDK signs the request from whatever credentials it finds (instance role, container role, or environment variables), and the gateway either recognizes the signature or it doesn't.

Taking the API off the public internet entirely

IAM auth stops anonymous requests. It doesn't stop someone from finding the public endpoint URL and hammering it with credential-stuffing attempts. The next step drops the API off the public internet entirely.

Switch the API Gateway endpoint type to PRIVATE, attach an interface VPC endpoint, and lock the resource policy to that one endpoint:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": "*",
      "Action": "execute-api:Invoke",
      "Resource": "arn:aws:execute-api:us-east-1:111122223333:a1b2c3d4e5/*",
      "Condition": {
        "StringEquals": { "aws:SourceVpce": "vpce-0abc123456789def0" }
      }
    },
    {
      "Effect": "Deny",
      "Principal": "*",
      "Action": "execute-api:Invoke",
      "Resource": "arn:aws:execute-api:us-east-1:111122223333:a1b2c3d4e5/*",
      "Condition": {
        "StringNotEquals": { "aws:SourceVpce": "vpce-0abc123456789def0" }
      }
    }
  ]
}

Now a correctly signed request from outside that one VPC endpoint still gets denied. AWS won't let a private API deploy without this resource policy attached, so the restriction can't be skipped by accident.

What this pattern doesn't fix

Worth saying plainly: none of this replaces monitoring. A stolen instance role's temporary credentials still work until they expire, so pair this with CloudTrail alerts on unusual execute-api:Invoke volume from a single principal. It also doesn't check device posture the way a full BeyondCorp-style rollout does, and a real production system usually needs both layers.

Recap, if you're skimming

  • Every request signed with SigV4, checked at the gateway, not the application code.

  • Every IAM role scoped to one resource and one or two actions, never a service-wide wildcard.

  • No SSH keys living on disk; temporary keys or none at all.

  • Network location (VPC, subnet, security group) used for isolation, never treated as proof of identity.

  • The API reachable only through a specific VPC endpoint once the pattern earns its way into production.

None of these pieces is exotic. IAM auth on API Gateway has existed for years, and DynamoDB encryption ships on by default.

The only real shift is a habit: stop asking "is this request inside the network," and start asking "can this request prove who it is." Everything else in this build is just IAM policy enforcing that one question.

Comments (1)

Join the discussion by logging into your account.

Hiroshi Tanaka

Hiroshi Tanaka

1 month ago

Hello! I really enjoyed reading your post. Regarding the project currently underway, could we communicate via the following contact details? Telegram: @bytepil0t WhatsApp: +1 (910) 852-7435

Tomson Alex
Tomson Alex

Blogger

Passionate developer sharing knowledge about modern web technologies and best practices.

Subscribe to Tomson Alex's Newsletter

Direct email dispatches when new stories are published. Zero algorithms.

Tomson Alex
Like
Love
Clap
Fire
Party
Wow

More from Tomson Alex

View profile

AntiSlop-vLLM: Architectural Review & Integration Assessment

AntiSlop-vLLM strips AI-sounding phrasing by resampling tokens mid-generation, but it needs an API that returns real per-token logprobs, something Groq's current API doesn't support. Covers the architecture, setup, and two backend-agnostic workarounds.

7 minSep 6

watermarks-remover: Architecture Review & Getting-Started Guide

An architecture-focused review of watermarks-remover, examining its three-layer design, skill/service split, detection pipeline, file-format handling, security posture, optional research backends, and the important distinction between verifiable removal and best-effort rewriting.

9 minSep 4